diff --git a/agent-team/web/src/components/Board.test.tsx b/agent-team/web/src/components/Board.test.tsx new file mode 100644 index 0000000..02ade2f --- /dev/null +++ b/agent-team/web/src/components/Board.test.tsx @@ -0,0 +1,275 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { Board } from "./Board"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { StateResponse, Topology } from "../api"; + +/** Wrap in TooltipProvider — the real app mounts this at the root. */ +function renderWithProviders(ui: ReactNode) { + return render({ui}); +} + +const TOPO: Topology = { + trees: [ + { id: "core", label: "Core" }, + { id: "sdlc", label: "SDLC" }, + ], + nodes: [ + { id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false }, + { id: "clarify", label: "Clarify", agent: "coordinator", tree: "core", kind: "gate", gated: true }, + { id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false }, + ], + edges: [], +}; + +function makeState(): StateResponse { + return { + ok: true, + error: null, + generated_at: "now", + db_path: "x", + stages: [ + { + key: "intake", + label: "Intake", + agent: "coordinator", + phases: ["intake"], + gated: false, + role_node: false, + state: "idle", + count: 1, + tasks: [ + { + thread_id: "aaaa1111", + short_id: "aaaa1111", + task: "remediate CVE", + current_phase: "intake", + status: "active", + waiting: false, + waiting_since: null, + }, + ], + }, + { + key: "clarify", + label: "Clarify", + agent: "coordinator", + phases: ["clarify"], + gated: true, + role_node: false, + state: "awaiting_human", + count: 1, + tasks: [ + { + thread_id: "bbbb2222", + short_id: "bbbb2222", + task: "fix login bug", + current_phase: "clarify", + status: "waiting_human", + waiting: true, + waiting_since: "2024-01-01T00:00:00Z", + }, + ], + }, + ], + tasks: [ + { + thread_id: "aaaa1111", + short_id: "aaaa1111", + task: "remediate CVE", + current_phase: "intake", + status: "active", + waiting: false, + waiting_since: null, + }, + { + thread_id: "bbbb2222", + short_id: "bbbb2222", + task: "fix login bug", + current_phase: "clarify", + status: "waiting_human", + waiting: true, + waiting_since: "2024-01-01T00:00:00Z", + }, + ], + waiting: [], + question_counts: {}, + summary: { active: 1, parked: 0, waiting: 1, open_questions: 0, total: 2 }, + budget: { available: false }, + nodes: {}, + }; +} + +describe("Board", () => { + it("renders a column per stage with correct labels", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + expect(screen.getByLabelText("Stage: Intake")).toBeInTheDocument(); + expect(screen.getByLabelText("Stage: Clarify")).toBeInTheDocument(); + }); + + it("shows task count badge in each column header", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + const intakeCol = screen.getByLabelText("Stage: Intake"); + // Both columns have 1 task each + expect(within(intakeCol).getByText("1")).toBeInTheDocument(); + + const clarifyCol = screen.getByLabelText("Stage: Clarify"); + expect(within(clarifyCol).getByText("1")).toBeInTheDocument(); + }); + + it("renders task descriptions inside columns", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + expect(screen.getByText("remediate CVE")).toBeInTheDocument(); + expect(screen.getByText("fix login bug")).toBeInTheDocument(); + }); + + it("shows a waiting affordance (clock icon) for a waiting task", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + // The Clock icon has aria-label="awaiting human" + expect(screen.getByLabelText("awaiting human")).toBeInTheDocument(); + }); + + it("shows lock icon on a gated stage column", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + expect(screen.getByLabelText("human gate")).toBeInTheDocument(); + }); + + it("clicking a card fires onSelect with the thread_id", () => { + const onSelect = vi.fn(); + renderWithProviders( + {}} + />, + ); + + fireEvent.click(screen.getByText("remediate CVE")); + expect(onSelect).toHaveBeenCalledWith("aaaa1111"); + }); + + it("selected card gets aria-selected=true", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + const cards = screen.getAllByRole("option", { selected: true }); + expect(cards).toHaveLength(1); + }); + + it("shows node filter chip when nodeFilter is set", () => { + const onClearNodeFilter = vi.fn(); + renderWithProviders( + {}} + onClearNodeFilter={onClearNodeFilter} + />, + ); + + const chip = screen.getByLabelText("Clear node filter: intake"); + expect(chip).toBeInTheDocument(); + fireEvent.click(chip); + expect(onClearNodeFilter).toHaveBeenCalled(); + }); + + it("falls back to deriving stages from tasks when stages is undefined", () => { + const stateNoStages: StateResponse = { + ...makeState(), + stages: undefined, + }; + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + // Should still show the tasks grouped by phase + expect(screen.getByText("remediate CVE")).toBeInTheDocument(); + expect(screen.getByText("fix login bug")).toBeInTheDocument(); + }); + + it("renders null state gracefully with loading message", () => { + renderWithProviders( + {}} + onClearNodeFilter={() => {}} + />, + ); + + expect(screen.getByText("Loading…")).toBeInTheDocument(); + }); +}); diff --git a/agent-team/web/src/components/Board.tsx b/agent-team/web/src/components/Board.tsx index e26f684..caf4c87 100644 --- a/agent-team/web/src/components/Board.tsx +++ b/agent-team/web/src/components/Board.tsx @@ -1,6 +1,22 @@ -import type { StateResponse, Topology } from "../api"; +import { useMemo, useState } from "react"; +import { Lock, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { TaskCard } from "./TaskCard"; +import { buildTreeByNode, filterTasks, phaseToNode } from "../taskFilters"; +import type { Stage, StateResponse, TaskView, Topology } from "../api"; -// STUB — replaced in the component fan-out. Keeps the foundation compiling. interface Props { state: StateResponse | null; topology: Topology | null; @@ -10,6 +26,245 @@ interface Props { onClearNodeFilter: () => void; } -export function Board(_props: Props) { - return
Board…
; +const STATUS_OPTIONS = [ + { value: "all", label: "All statuses" }, + { value: "active", label: "Active" }, + { value: "waiting_human", label: "Waiting human" }, + { value: "parked", label: "Parked" }, + { value: "done", label: "Done" }, + { value: "failed", label: "Failed" }, +]; + +/** Derive ordered stages from topology node order when state.stages is absent. */ +function deriveStages(tasks: TaskView[], topology: Topology | null): Stage[] { + // Build node order from topology; fall back to natural insertion order. + const nodeOrder: string[] = topology?.nodes.map((n) => n.id) ?? []; + + const grouped = new Map(); + for (const t of tasks) { + const node = phaseToNode(t.current_phase); + if (!grouped.has(node)) grouped.set(node, []); + grouped.get(node)!.push(t); + } + + // Sort by topology order; unknown nodes go at end. + const nodeIds = [...grouped.keys()].sort((a, b) => { + const ai = nodeOrder.indexOf(a); + const bi = nodeOrder.indexOf(b); + if (ai === -1 && bi === -1) return 0; + if (ai === -1) return 1; + if (bi === -1) return -1; + return ai - bi; + }); + + return nodeIds.map((nodeId) => { + const topoNode = topology?.nodes.find((n) => n.id === nodeId); + const stageTasks = grouped.get(nodeId) ?? []; + return { + key: nodeId, + label: topoNode?.label ?? nodeId, + agent: topoNode?.agent ?? "", + phases: [nodeId], + gated: topoNode?.gated ?? false, + role_node: false, + state: "idle" as const, + count: stageTasks.length, + tasks: stageTasks, + }; + }); +} + +/** Check if a stage is a human gate (stage.gated OR topology node kind === 'gate'). */ +function isGate(stage: Stage, topology: Topology | null): boolean { + if (stage.gated) return true; + const topoNode = topology?.nodes.find((n) => n.id === stage.key); + return topoNode?.kind === "gate"; +} + +export function Board({ + state, + topology, + selectedId, + nodeFilter, + onSelect, + onClearNodeFilter, +}: Props) { + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("all"); + const [tree, setTree] = useState("all"); + + const treeByNode = useMemo(() => buildTreeByNode(topology), [topology]); + + // Use backend-provided stages or fall back to derived grouping. + const allStages: Stage[] = useMemo(() => { + if (!state) return []; + if (state.stages && state.stages.length > 0) return state.stages; + return deriveStages(state.tasks ?? [], topology); + }, [state, topology]); + + // Filter each stage's tasks by the toolbar + nodeFilter params. + const filteredStages = useMemo(() => { + return allStages.map((stage) => { + const filtered = filterTasks(stage.tasks, { + query, + status, + tree, + nodeFilter, + treeByNode, + }); + return { ...stage, tasks: filtered, count: filtered.length }; + }); + }, [allStages, query, status, tree, nodeFilter, treeByNode]); + + if (!state) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ setQuery(e.target.value)} + className="h-8 w-48 text-sm" + /> + + + + + + {/* Node filter chip */} + {nodeFilter && ( + + )} +
+ + {/* Kanban columns — horizontal scroll */} +
+ {filteredStages.map((stage) => ( + + ))} + + {filteredStages.length === 0 && ( +
+ No stages available. +
+ )} +
+
+ ); +} + +interface ColumnProps { + stage: Stage; + topology: Topology | null; + selectedId: string | null; + onSelect: (threadId: string) => void; +} + +function KanbanColumn({ stage, topology, selectedId, onSelect }: ColumnProps) { + const gate = isGate(stage, topology); + + return ( +
+ {/* Column header */} +
+ {stage.label} +
+ + {stage.count} + + {gate && ( + + + + + human gate + + )} +
+
+ + {/* Task cards — scrollable vertically */} + +
+ {stage.tasks.length === 0 ? ( +

—

+ ) : ( + stage.tasks.map((t) => ( + + )) + )} +
+
+
+ ); } diff --git a/agent-team/web/src/components/MapNode.tsx b/agent-team/web/src/components/MapNode.tsx index 6f888fd..95ac022 100644 --- a/agent-team/web/src/components/MapNode.tsx +++ b/agent-team/web/src/components/MapNode.tsx @@ -1,5 +1,8 @@ import { memo } from "react"; import { Handle, Position, type NodeProps } from "reactflow"; +import { Lock, RotateCcw } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import type { RFData } from "../layout"; // One pipeline stage on the map. Color encodes live state; a badge shows the @@ -7,49 +10,80 @@ import type { RFData } from "../layout"; // label keep the map reachable for screen readers / keyboard users. function MapNodeImpl({ data }: NodeProps) { const { meta, state, count, dimmed, onPath, loopBackTo } = data; - const cls = [ - "node", - `state-${state}`, - meta.gated ? "gated" : "", - dimmed ? "dimmed" : "", - onPath ? "on-path" : "", - ] - .filter(Boolean) - .join(" "); const canLoop = loopBackTo.length > 0; return (
-
- {meta.label} - {meta.kind === "gate" && ( - - ⛋ - - )} - {canLoop && ( - - ↺ - - )} -
-
{meta.agent}
+ + {/* Count badge — top-right corner */} {count > 0 && ( - + {count} )} - {meta.gated && inert} + + {/* Label row */} +
+ {meta.label} + + {meta.kind === "gate" && ( + + + + )} + + {canLoop && ( + + + + + + + + can send work back to {loopBackTo.join(", ")} + + + )} +
+ + {/* Agent name */} +
+ {meta.agent} +
+ + {/* Inert tag — bottom-right corner */} + {meta.gated && ( + + inert + + )} +
); diff --git a/agent-team/web/src/components/PipelineMap.tsx b/agent-team/web/src/components/PipelineMap.tsx index 7a842a0..b6d25f2 100644 --- a/agent-team/web/src/components/PipelineMap.tsx +++ b/agent-team/web/src/components/PipelineMap.tsx @@ -39,7 +39,7 @@ export function PipelineMap({ const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id); return ( -
+
void; +} + +export function TaskCard({ task, selected, onSelect }: TaskCardProps) { + function handleClick() { + onSelect(task.thread_id); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(task.thread_id); + } + } + + return ( +
+ {/* Top row: status badge + waiting clock */} +
+ + {task.status.replace("_", " ")} + + {task.waiting && ( + + )} +
+ + {/* Task description — 2-line clamp */} +

+ {task.task} +

+ + {/* Short ID in mono muted text */} +

{task.short_id}

+
+ ); +} diff --git a/agent-team/web/src/components/TaskDrawer.tsx b/agent-team/web/src/components/TaskDrawer.tsx index 7f97dd4..7abfe8d 100644 --- a/agent-team/web/src/components/TaskDrawer.tsx +++ b/agent-team/web/src/components/TaskDrawer.tsx @@ -1,5 +1,17 @@ +import { MessageSquare, X, ChevronDown } from "lucide-react"; import type { TaskDetail } from "../api"; import { Markdown } from "./Markdown"; +import { Badge, statusVariant } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; interface Props { detail: TaskDetail | null; @@ -24,91 +36,246 @@ function fmtCost(usd: number): string { export function TaskDrawer({ detail, loading, error, onClose }: Props) { if (!detail && !loading && !error) return null; + const isWaiting = detail?.ok && detail.status === "waiting_human"; + const isClarifyGate = + isWaiting && detail?.current_phase === "clarify"; + return ( -
-
-

Task history

- +
+ {/* Sticky header */} +
+

Task history

+
- {loading &&
Loading…
} - {error &&
{error}
} + {loading && ( +
+ Loading… +
+ )} + {error && ( +
+ {error} +
+ )} {detail && detail.ok && ( - <> -
-
{detail.task}
-
- {detail.status} - {detail.short_id} - spend {fmtCost(detail.total_usd)} -
- {detail.partial && ( -
- Partial history — recorded since the v3 ledger; earlier steps are - reconstructed. -
- )} -
+ +
+ {/* Summary card */} + + +

+ {detail.task} +

+
+ + {detail.status} + + + {detail.short_id} + + + spend {fmtCost(detail.total_usd)} + +
+ {detail.partial && ( +

+ Partial history — recorded since the v3 ledger; earlier + steps are reconstructed. +

+ )} +
+
-
    - {detail.timeline.map((step, i) => { - const open = step.exited_at == null; - return ( -
  1. - -
    -
    {step.to_phase}
    -
    - {step.entered_at ?? "—"} - - {open ? "in progress" : fmtDuration(step.duration_s)} - -
    -
    - {step.status && {step.status}} - {fmtCost(step.cost_usd)} - {step.note && {step.note}} + {/* Gate panel — waiting_human informational only */} + {isWaiting && ( + + +
    + +
    +

    + Input requested +

    +

    + {isClarifyGate + ? "The pipeline needs clarification before planning." + : "The pipeline is awaiting a human decision to proceed."} +

    +

    + Answer in Slack{" "} + #agent-team +

    -
  2. - ); - })} - {detail.timeline.length === 0 && ( -
  3. No transitions recorded.
  4. + + )} -
- {detail.qa_history.length > 0 && ( -
- Q&A ({detail.qa_history.length}) - -
- )} - {detail.review_verdicts.length > 0 && ( -
- Review verdicts ({detail.review_verdicts.length}) - -
- )} - {detail.plan && ( -
- Plan - -
- )} - + {/* Timeline */} +
+

+ Timeline +

+
    + {detail.timeline.map((step, i) => { + const open = step.exited_at == null; + const isLast = i === detail.timeline.length - 1; + return ( +
  1. + {/* Dot + connector */} +
    + + {!isLast && ( + + )} +
    + + {/* Step body */} +
    +
    + + {step.to_phase} + + + {open ? "in progress" : fmtDuration(step.duration_s)} + +
    +
    + {step.entered_at && ( + + {step.entered_at} + + )} + {step.status && ( + + {step.status} + + )} + {step.cost_usd > 0 && ( + + {fmtCost(step.cost_usd)} + + )} + {step.note && ( + + {step.note} + + )} +
    +
    +
  2. + ); + })} + {detail.timeline.length === 0 && ( +
  3. + No transitions recorded. +
  4. + )} +
+
+ + {/* Q&A section */} + {detail.qa_history.length > 0 && ( + <> + + + + + + )} + + {/* Review verdicts */} + {detail.review_verdicts.length > 0 && ( + <> + + + + + + )} + + {/* Plan */} + {detail.plan && ( + <> + + + + + + )} +
+
)} {detail && !detail.ok && ( -
{detail.error ?? "Could not load task."}
+
+ {detail.error ?? "Could not load task."} +
)}
); } +// Collapsible section wrapper used for Q&A / Verdicts / Plan +function CollapsibleSection({ + title, + defaultOpen, + children, +}: { + title: string; + defaultOpen: boolean; + children: React.ReactNode; +}) { + return ( + + + + {title} + + + + +
{children}
+
+
+ ); +} + function asText(v: unknown): string { return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2); } @@ -118,17 +285,23 @@ function asText(v: unknown): string { // as markdown so numbered sub-answers / bold render readably. function QASection({ entries }: { entries: unknown[] }) { return ( -
+
{entries.map((entry, i) => { const obj = entry && typeof entry === "object" ? (entry as Record) : null; const turn = obj && "turn" in obj ? Number(obj.turn) : i; const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry); return ( -
-
Turn {turn + 1}
- {answer} -
+ + + + Turn {turn + 1} + + + + {answer} + + ); })}
@@ -139,7 +312,7 @@ function QASection({ entries }: { entries: unknown[] }) { // verdict badge + round + outcome header and the findings rendered as markdown. function VerdictsSection({ entries }: { entries: unknown[] }) { return ( -
+
{entries.map((entry, i) => { const v = entry && typeof entry === "object" ? (entry as Record) : {}; @@ -147,16 +320,29 @@ function VerdictsSection({ entries }: { entries: unknown[] }) { const round = "round_index" in v ? Number(v.round_index) : i + 1; const outcome = asText(v.outcome); const findings = asText(v.findings); - const cls = verdict.replace(/[^a-z_]/gi, "_").toLowerCase(); return ( -
-
- Round {round} - {verdict.replace(/_/g, " ")} - {outcome && {outcome.replace(/_/g, " ")}} -
- {findings && {findings}} -
+ + +
+ + Round {round} + + + {verdict.replace(/_/g, " ")} + + {outcome && ( + + {outcome.replace(/_/g, " ")} + + )} +
+
+ {findings && ( + + {findings} + + )} +
); })}
@@ -176,27 +362,40 @@ function PlanSection({ plan }: { plan: Record }) { if (!summary && !phases) { return ( -
{JSON.stringify(plan, null, 2)}
+
+        {JSON.stringify(plan, null, 2)}
+      
); } return ( -
- {summary && {summary}} +
+ {summary && ( +
+ {summary} +
+ )} {phases && ( -
    +
      {phases.map((p, i) => { const ph = p && typeof p === "object" ? (p as Record) : {}; const name = asText(ph.name) || `Phase ${i + 1}`; const steps = Array.isArray(ph.steps) ? ph.steps : []; return ( -
    1. -
      {name}
      +
    2. +

      + {name} +

      {steps.length > 0 && ( -
        +
          {steps.map((s, j) => ( -
        • {asText(s)}
        • +
        • + {asText(s)} +
        • ))}
        )} @@ -206,11 +405,11 @@ function PlanSection({ plan }: { plan: Record }) {
    )} {extra.length > 0 && ( -
    +
    {extra.map(([k, val]) => ( -
    -
    {k}
    -
    {asText(val)}
    +
    +
    {k}
    +
    {asText(val)}
    ))}
    diff --git a/agent-team/web/src/components/TaskList.test.tsx b/agent-team/web/src/components/TaskList.test.tsx deleted file mode 100644 index a3e0f94..0000000 --- a/agent-team/web/src/components/TaskList.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { TaskList } from "./TaskList"; -import type { StateResponse, Topology } from "../api"; - -const TOPO: Topology = { - trees: [ - { id: "core", label: "Core" }, - { id: "sdlc", label: "SDLC" }, - ], - nodes: [ - { id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false }, - { id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false }, - ], - edges: [], -}; - -function makeState(): StateResponse { - return { - ok: true, - error: null, - generated_at: "now", - db_path: "x", - tasks: [ - { thread_id: "aaaa1111", short_id: "aaaa1111", task: "remediate CVE", current_phase: "plan", status: "active", waiting: false, waiting_since: null }, - { thread_id: "bbbb2222", short_id: "bbbb2222", task: "fix login bug", current_phase: "intake", status: "parked", waiting: false, waiting_since: null }, - ], - waiting: [], - question_counts: {}, - summary: { active: 1, parked: 1, waiting: 0, open_questions: 0, total: 2 }, - budget: { available: false }, - nodes: {}, - }; -} - -describe("TaskList", () => { - it("renders all tasks", () => { - render( - {}} onClearNodeFilter={() => {}} />, - ); - expect(screen.getByText("remediate CVE")).toBeInTheDocument(); - expect(screen.getByText("fix login bug")).toBeInTheDocument(); - }); - - it("filters by status", () => { - render( - {}} onClearNodeFilter={() => {}} />, - ); - fireEvent.change(screen.getByLabelText("Filter by status"), { target: { value: "parked" } }); - expect(screen.queryByText("remediate CVE")).not.toBeInTheDocument(); - expect(screen.getByText("fix login bug")).toBeInTheDocument(); - }); - - it("honors a node filter (map click)", () => { - render( - {}} onClearNodeFilter={() => {}} />, - ); - expect(screen.getByText("remediate CVE")).toBeInTheDocument(); - expect(screen.queryByText("fix login bug")).not.toBeInTheDocument(); - }); - - it("fires onSelect when a row is clicked", () => { - const onSelect = vi.fn(); - render( - {}} />, - ); - fireEvent.click(screen.getByText("remediate CVE")); - expect(onSelect).toHaveBeenCalledWith("aaaa1111"); - }); -}); diff --git a/agent-team/web/src/components/TaskList.tsx b/agent-team/web/src/components/TaskList.tsx deleted file mode 100644 index 50088c9..0000000 --- a/agent-team/web/src/components/TaskList.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useMemo, useState } from "react"; -import type { StateResponse, TaskView, Topology } from "../api"; - -// Mirror of the backend phase->node mapping (topology.PHASE_TO_NODE) so the list -// can resolve a task's tree and honor a map-node filter. Identity except the P3 -// build/verify vertices. -const PHASE_TO_NODE: Record = { - build: "build_node", - verify: "verify_node", -}; -const phaseToNode = (phase: string): string => PHASE_TO_NODE[phase] ?? phase; - -interface Props { - state: StateResponse | null; - topology: Topology | null; - selectedId: string | null; - nodeFilter: string | null; - onSelect: (threadId: string) => void; - onClearNodeFilter: () => void; -} - -const STATUS_OPTIONS = ["all", "active", "waiting_human", "parked", "done", "failed"]; - -export function TaskList({ - state, - topology, - selectedId, - nodeFilter, - onSelect, - onClearNodeFilter, -}: Props) { - const [query, setQuery] = useState(""); - const [status, setStatus] = useState("all"); - const [tree, setTree] = useState("all"); - - const treeByNode = useMemo(() => { - const m: Record = {}; - for (const n of topology?.nodes ?? []) m[n.id] = n.tree; - return m; - }, [topology]); - - const tasks = state?.tasks ?? []; - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - return tasks.filter((t) => { - if (nodeFilter && phaseToNode(t.current_phase) !== nodeFilter) return false; - if (status !== "all" && t.status !== status) return false; - if (tree !== "all" && treeByNode[phaseToNode(t.current_phase)] !== tree) - return false; - if (q && !(`${t.task} ${t.short_id}`.toLowerCase().includes(q))) return false; - return true; - }); - }, [tasks, query, status, tree, nodeFilter, treeByNode]); - - return ( - - ); -} - -function TaskRow({ - task, - selected, - onSelect, -}: { - task: TaskView; - selected: boolean; - onSelect: (id: string) => void; -}) { - return ( -
  1. onSelect(task.thread_id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect(task.thread_id); - } - }} - > -
    - {task.status.replace("_", " ")} - {task.current_phase} - {task.waiting && ⏳} -
    -
    {task.task}
    -
    {task.short_id}
    -
  2. - ); -} diff --git a/agent-team/web/src/components/TopBar.tsx b/agent-team/web/src/components/TopBar.tsx index 8e872dc..2b957f2 100644 --- a/agent-team/web/src/components/TopBar.tsx +++ b/agent-team/web/src/components/TopBar.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import type { StateResponse } from "../api"; interface Props { @@ -6,36 +7,118 @@ interface Props { generatedAt: string | null; } -const CHIPS: Array<{ key: keyof StateResponse["summary"]; label: string; cls: string }> = - [ - { key: "active", label: "Active", cls: "active" }, - { key: "waiting", label: "Awaiting human", cls: "waiting" }, - { key: "parked", label: "Parked", cls: "parked" }, - { key: "open_questions", label: "Open questions", cls: "open" }, - { key: "total", label: "Total", cls: "total" }, - ]; +interface ChipDef { + key: keyof StateResponse["summary"]; + label: string; + numCls: string; + bgCls: string; +} -// Top status bar: summary chips, a connection/health dot, and the snapshot clock. +const CHIPS: ChipDef[] = [ + { + key: "active", + label: "ACTIVE", + numCls: "text-status-active", + bgCls: "bg-status-active-bg", + }, + { + key: "waiting", + label: "AWAITING HUMAN", + numCls: "text-status-waiting", + bgCls: "bg-status-waiting-bg", + }, + { + key: "parked", + label: "PARKED", + numCls: "text-status-parked", + bgCls: "bg-status-parked-bg", + }, + { + key: "open_questions", + label: "OPEN QUESTIONS", + numCls: "text-status-waiting", + bgCls: "bg-status-waiting-bg", + }, + { + key: "total", + label: "TOTAL", + numCls: "text-muted-foreground", + bgCls: "bg-muted", + }, +]; + +// Top status bar: brand, summary chips, connection health, and snapshot clock. export function TopBar({ state, connected, generatedAt }: Props) { const summary = state?.summary; + const title = connected + ? `Live — snapshot at ${generatedAt ?? "—"}` + : `Disconnected — last snapshot at ${generatedAt ?? "—"}`; + return ( -
    -
    - - agent-team - pipeline +
    + {/* Brand */} +
    + + + agent-team + + + pipeline +
    -
    + + {/* Divider */} +
    + + {/* Summary chips */} +
    {CHIPS.map((c) => ( -
    - {summary ? summary[c.key] : "–"} - {c.label} +
    + + {summary ? summary[c.key] : "–"} + + + {c.label} +
    ))}
    -
    - - {generatedAt ?? "—"} + + {/* Health indicator */} +
    + + + {generatedAt ?? "—"} +
    ); diff --git a/agent-team/web/src/index.css b/agent-team/web/src/index.css index a33bfa0..c5cd8d4 100644 --- a/agent-team/web/src/index.css +++ b/agent-team/web/src/index.css @@ -92,3 +92,71 @@ .react-flow__attribution { display: none; } + +/* + * Markdown content (Q&A answers / review findings / plan summaries), rendered by + * the XSS-safe component. Ported from theme.css onto the new tokens. + */ +.md { + font-size: 0.82rem; + color: hsl(var(--foreground)); + overflow-wrap: anywhere; +} +.md > :first-child { + margin-top: 0; +} +.md > :last-child { + margin-bottom: 0; +} +.md p { + margin: 0.4rem 0; +} +.md ul, +.md ol { + margin: 0.4rem 0; + padding-left: 1.2rem; +} +.md li { + margin: 0.15rem 0; +} +.md strong { + color: hsl(var(--foreground)); + font-weight: 700; +} +.md h1, +.md h2, +.md h3, +.md h4 { + font-size: 0.9rem; + margin: 0.6rem 0 0.3rem; + color: hsl(var(--status-done)); +} +.md code { + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 4px; + padding: 0 0.25rem; + font-size: 0.92em; + font-family: theme("fontFamily.mono"); +} +.md pre { + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 0.5rem; + overflow-x: auto; +} +.md pre code { + border: 0; + padding: 0; + background: none; +} +.md a { + color: hsl(var(--status-done)); +} +.md blockquote { + margin: 0.4rem 0; + padding-left: 0.7rem; + border-left: 2px solid hsl(var(--input)); + color: hsl(var(--muted-foreground)); +} diff --git a/agent-team/web/src/main.tsx b/agent-team/web/src/main.tsx index b6ef25c..c2a145c 100644 --- a/agent-team/web/src/main.tsx +++ b/agent-team/web/src/main.tsx @@ -2,10 +2,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; -// theme.css is being retired as components migrate to Tailwind; kept until the -// last component is converted so intermediate builds still render (removed in -// the integration phase). -import "./theme.css"; createRoot(document.getElementById("root")!).render( diff --git a/agent-team/web/src/taskFilters.test.ts b/agent-team/web/src/taskFilters.test.ts new file mode 100644 index 0000000..21ae462 --- /dev/null +++ b/agent-team/web/src/taskFilters.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { filterTasks, phaseToNode } from "./taskFilters"; +import type { TaskView } from "./api"; + +const TREE_BY_NODE: Record = { + intake: "core", + clarify: "core", + plan: "sdlc", + review: "sdlc", + build_node: "sdlc", + verify_node: "sdlc", +}; + +function task(overrides: Partial = {}): TaskView { + return { + thread_id: "aaaa1111", + short_id: "aaaa1111", + task: "remediate CVE-2024-1234", + current_phase: "plan", + status: "active", + waiting: false, + waiting_since: null, + ...overrides, + }; +} + +const BASE_PARAMS = { + query: "", + status: "all", + tree: "all", + nodeFilter: null, + treeByNode: TREE_BY_NODE, +}; + +describe("phaseToNode", () => { + it("returns the phase itself for standard phases", () => { + expect(phaseToNode("intake")).toBe("intake"); + expect(phaseToNode("plan")).toBe("plan"); + expect(phaseToNode("clarify")).toBe("clarify"); + }); + + it("maps build -> build_node and verify -> verify_node", () => { + expect(phaseToNode("build")).toBe("build_node"); + expect(phaseToNode("verify")).toBe("verify_node"); + }); +}); + +describe("filterTasks", () => { + it("returns all tasks when no filters applied", () => { + const tasks = [task(), task({ thread_id: "bbbb2222", short_id: "bbbb2222" })]; + expect(filterTasks(tasks, BASE_PARAMS)).toHaveLength(2); + }); + + it("filters by query matching task description", () => { + const tasks = [ + task({ task: "remediate CVE" }), + task({ thread_id: "bbbb", short_id: "bbbb", task: "fix login bug" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, query: "CVE" }); + expect(result).toHaveLength(1); + expect(result[0].task).toBe("remediate CVE"); + }); + + it("filters by query matching short_id", () => { + const tasks = [ + task({ short_id: "abc123" }), + task({ thread_id: "bbbb", short_id: "xyz789", task: "other task" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, query: "abc" }); + expect(result).toHaveLength(1); + expect(result[0].short_id).toBe("abc123"); + }); + + it("query matching is case-insensitive", () => { + const tasks = [task({ task: "Remediate CVE" })]; + expect(filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" })).toHaveLength(1); + }); + + it("filters by status", () => { + const tasks = [ + task({ status: "active" }), + task({ thread_id: "bbbb", short_id: "bbbb", status: "parked" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, status: "parked" }); + expect(result).toHaveLength(1); + expect(result[0].status).toBe("parked"); + }); + + it("status='all' does not filter by status", () => { + const tasks = [ + task({ status: "active" }), + task({ thread_id: "bbbb", short_id: "bbbb", status: "done" }), + ]; + expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(2); + }); + + it("filters by tree", () => { + const tasks = [ + task({ current_phase: "intake" }), // core tree + task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), // sdlc tree + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, tree: "sdlc" }); + expect(result).toHaveLength(1); + expect(result[0].current_phase).toBe("plan"); + }); + + it("filters by nodeFilter (exact node match)", () => { + const tasks = [ + task({ current_phase: "plan" }), + task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "intake" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "plan" }); + expect(result).toHaveLength(1); + expect(result[0].current_phase).toBe("plan"); + }); + + it("nodeFilter resolves build -> build_node", () => { + const tasks = [ + task({ current_phase: "build" }), + task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "build_node" }); + expect(result).toHaveLength(1); + expect(result[0].current_phase).toBe("build"); + }); + + it("combines query + status filters", () => { + const tasks = [ + task({ task: "fix login", status: "active" }), + task({ thread_id: "b", short_id: "b", task: "fix login", status: "parked" }), + task({ thread_id: "c", short_id: "c", task: "other task", status: "active" }), + ]; + const result = filterTasks(tasks, { ...BASE_PARAMS, query: "fix login", status: "active" }); + expect(result).toHaveLength(1); + expect(result[0].thread_id).toBe("aaaa1111"); + }); + + it("returns empty array when nothing matches", () => { + const tasks = [task()]; + expect(filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" })).toHaveLength(0); + }); + + it("handles empty task list", () => { + expect(filterTasks([], BASE_PARAMS)).toHaveLength(0); + }); +}); diff --git a/agent-team/web/src/taskFilters.ts b/agent-team/web/src/taskFilters.ts new file mode 100644 index 0000000..755a423 --- /dev/null +++ b/agent-team/web/src/taskFilters.ts @@ -0,0 +1,47 @@ +// Pure filter helpers — shared by Board and any future list views. +// Lifted from TaskList.tsx so logic is testable in isolation. + +import type { TaskView, Topology } from "./api"; + +// Mirror of the backend phase->node mapping. Identity for most phases; +// build/verify are the only overrides (P3 build-pipeline vertices). +const PHASE_TO_NODE_MAP: Record = { + build: "build_node", + verify: "verify_node", +}; + +export function phaseToNode(phase: string): string { + return PHASE_TO_NODE_MAP[phase] ?? phase; +} + +/** Build a node-id -> tree-id lookup from topology. */ +export function buildTreeByNode(topology: Topology | null): Record { + const m: Record = {}; + for (const n of topology?.nodes ?? []) m[n.id] = n.tree; + return m; +} + +export interface FilterParams { + query: string; + status: string; // "all" | task status value + tree: string; // "all" | tree id + nodeFilter: string | null; + treeByNode: Record; +} + +/** Filter an array of TaskView objects by the given params. */ +export function filterTasks(tasks: TaskView[], params: FilterParams): TaskView[] { + const { query, status, tree, nodeFilter, treeByNode } = params; + const q = query.trim().toLowerCase(); + + return tasks.filter((t) => { + const node = phaseToNode(t.current_phase); + + if (nodeFilter && node !== nodeFilter) return false; + if (status !== "all" && t.status !== status) return false; + if (tree !== "all" && treeByNode[node] !== tree) return false; + if (q && !`${t.task} ${t.short_id}`.toLowerCase().includes(q)) return false; + + return true; + }); +} diff --git a/agent-team/web/src/theme.css b/agent-team/web/src/theme.css deleted file mode 100644 index 15a9bf4..0000000 --- a/agent-team/web/src/theme.css +++ /dev/null @@ -1,227 +0,0 @@ -:root { - --bg: #0c0e13; - --bg-1: #12151c; - --bg-2: #1a1e26; - --border: #232a36; - --border-2: #2f3645; - --text: #e6e8ee; - --text-dim: #8a93a6; - --accent: #7a5cff; - --accent-2: #3f7fd8; - --ok: #2f8f5e; - --ok-bg: #163a2b; - --ok-fg: #6ee7a8; - --warn-bg: #3a3216; - --warn-fg: #f5d76e; - --park-bg: #3a1f16; - --park-fg: #f3a36e; - --done-bg: #1c2b3a; - --done-fg: #7fb3f5; - --fail-bg: #3a1620; - --fail-fg: #f57f9c; - color-scheme: dark; -} - -* { box-sizing: border-box; } -html, body, #root { height: 100%; margin: 0; } -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, - Arial, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.45; -} -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - -.app { display: flex; flex-direction: column; height: 100%; } -.body { display: grid; grid-template-columns: 320px 1fr 360px; flex: 1; min-height: 0; } - -/* --- Top bar -------------------------------------------------------------- */ -.topbar { - display: flex; align-items: center; gap: 1.5rem; - padding: .6rem 1rem; background: var(--bg-1); - border-bottom: 1px solid var(--border); -} -.brand { display: flex; align-items: baseline; gap: .5rem; } -.brand-dot { - width: 10px; height: 10px; border-radius: 50%; - background: linear-gradient(135deg, var(--accent), var(--accent-2)); - align-self: center; -} -.brand-name { font-weight: 700; letter-spacing: .01em; } -.brand-sub { color: var(--text-dim); font-size: .85rem; } -.chips { display: flex; gap: .6rem; flex: 1; } -.chip { - display: flex; flex-direction: column; align-items: center; - background: var(--bg-2); border: 1px solid var(--border); - border-radius: 8px; padding: .3rem .7rem; min-width: 4.5rem; -} -.chip-num { font-size: 1.25rem; font-weight: 700; } -.chip-lbl { font-size: .66rem; text-transform: uppercase; letter-spacing: .05em; - color: var(--text-dim); } -.chip-active .chip-num { color: var(--ok-fg); } -.chip-waiting .chip-num { color: var(--warn-fg); } -.chip-parked .chip-num { color: var(--park-fg); } -.health { display: flex; align-items: center; gap: .5rem; color: var(--text-dim); - font-size: .8rem; } -.health-dot { width: 8px; height: 8px; border-radius: 50%; } -.health-dot.ok { background: var(--ok-fg); box-shadow: 0 0 6px var(--ok); } -.health-dot.down { background: var(--fail-fg); } - -/* --- Task list ------------------------------------------------------------ */ -.tasklist { background: var(--bg-1); border-right: 1px solid var(--border); - display: flex; flex-direction: column; min-height: 0; } -.tasklist-controls { padding: .75rem; border-bottom: 1px solid var(--border); - display: flex; flex-direction: column; gap: .5rem; } -.search, select { - background: var(--bg-2); color: var(--text); border: 1px solid var(--border-2); - border-radius: 6px; padding: .4rem .55rem; font-size: .85rem; width: 100%; -} -.filters { display: flex; gap: .5rem; } -.filter-chip { background: var(--bg-2); color: var(--accent); cursor: pointer; - border: 1px solid var(--accent); border-radius: 999px; padding: .2rem .6rem; - font-size: .75rem; align-self: flex-start; } -.tasks { list-style: none; margin: 0; padding: .5rem; overflow-y: auto; flex: 1; } -.task-row { background: var(--bg-2); border: 1px solid var(--border); - border-radius: 8px; padding: .55rem .65rem; margin-bottom: .5rem; cursor: pointer; - transition: border-color .15s, transform .05s; } -.task-row:hover { border-color: var(--border-2); } -.task-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -.task-row.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } -.task-row-top { display: flex; align-items: center; gap: .5rem; margin-bottom: .25rem; } -.task-phase { color: var(--text-dim); font-size: .75rem; } -.task-wait { margin-left: auto; } -.task-desc { font-size: .85rem; overflow: hidden; text-overflow: ellipsis; - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.task-id { color: var(--text-dim); font-size: .72rem; margin-top: .25rem; } -.empty { color: var(--text-dim); font-style: italic; padding: .5rem; } - -/* --- Badges -------------------------------------------------------------- */ -.badge { display: inline-block; padding: .08rem .45rem; border-radius: 999px; - font-size: .72rem; font-weight: 600; } -.badge.active { background: var(--ok-bg); color: var(--ok-fg); } -.badge.waiting_human, .badge.waiting { background: var(--warn-bg); color: var(--warn-fg); } -.badge.parked { background: var(--park-bg); color: var(--park-fg); } -.badge.done { background: var(--done-bg); color: var(--done-fg); } -.badge.failed { background: var(--fail-bg); color: var(--fail-fg); } -.badge.unknown { background: var(--bg-2); color: var(--text-dim); } - -/* --- Map ----------------------------------------------------------------- */ -.center { min-height: 0; min-width: 0; position: relative; } -.map-pane { width: 100%; height: 100%; } -.loading { padding: 2rem; color: var(--text-dim); } -.react-flow__attribution { display: none; } - -.node { - width: 184px; height: 72px; background: var(--bg-2); - border: 1.5px solid var(--border-2); border-radius: 10px; - padding: .5rem .6rem; display: flex; flex-direction: column; justify-content: center; - position: relative; transition: border-color .25s, background .25s, opacity .2s; -} -.node:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -.node-label { font-weight: 700; font-size: .92rem; display: flex; gap: .3rem; - align-items: center; } -.gate-pip { color: var(--warn-fg); font-size: .8rem; } -.node-agent { color: var(--text-dim); font-size: .72rem; margin-top: .15rem; } -.node-count { position: absolute; top: -8px; right: -8px; min-width: 20px; height: 20px; - border-radius: 999px; background: var(--accent); color: #0c0e13; font-weight: 800; - font-size: .72rem; display: flex; align-items: center; justify-content: center; - padding: 0 5px; } -.node-gated-tag { position: absolute; bottom: 4px; right: 8px; font-size: .58rem; - letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); } -.node.state-active { border-color: var(--ok); background: var(--ok-bg); } -.node.state-awaiting_human { border-color: var(--warn-fg); background: var(--warn-bg); } -.node.state-parked { border-color: var(--park-fg); background: var(--park-bg); } -.node.gated { opacity: .62; } -.node.dimmed { opacity: .28; } -.node.on-path { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(122, 92, 255, .4); } - -/* --- Drawer -------------------------------------------------------------- */ -.drawer { background: var(--bg-1); border-left: 1px solid var(--border); - display: flex; flex-direction: column; min-height: 0; overflow-y: auto; } -.drawer-head { display: flex; align-items: center; justify-content: space-between; - padding: .75rem 1rem; border-bottom: 1px solid var(--border); position: sticky; - top: 0; background: var(--bg-1); } -.drawer-head h2 { font-size: 1rem; margin: 0; color: #9fb4d6; } -.drawer-close { background: none; border: none; color: var(--text-dim); - font-size: 1rem; cursor: pointer; } -.drawer-close:focus-visible { outline: 2px solid var(--accent); } -.drawer-loading, .drawer-error, .drawer-section { padding: 1rem; } -.drawer-error { color: var(--fail-fg); } -.drawer-summary { padding: 1rem; border-bottom: 1px solid var(--border); } -.drawer-task { font-size: .95rem; margin-bottom: .5rem; } -.drawer-meta { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; - font-size: .8rem; color: var(--text-dim); } -.spend { margin-left: auto; color: var(--text); } -.partial-banner { margin-top: .6rem; background: var(--warn-bg); color: var(--warn-fg); - border-radius: 6px; padding: .5rem .6rem; font-size: .76rem; } - -.timeline { list-style: none; margin: 0; padding: 1rem 1rem 0; } -.tl-step { display: flex; gap: .7rem; padding-bottom: 1rem; position: relative; } -.tl-step:not(:last-child)::before { content: ""; position: absolute; left: 5px; - top: 14px; bottom: 0; width: 2px; background: var(--border-2); } -.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); - margin-top: 2px; flex-shrink: 0; z-index: 1; } -.tl-step.open .tl-dot { background: var(--warn-fg); box-shadow: 0 0 6px var(--warn-fg); } -.tl-body { flex: 1; } -.tl-node { font-weight: 700; font-size: .88rem; } -.tl-times { display: flex; justify-content: space-between; color: var(--text-dim); - font-size: .72rem; margin: .15rem 0; } -.tl-dur { color: var(--text); } -.tl-foot { display: flex; gap: .5rem; align-items: center; font-size: .72rem; } -.tl-cost { color: var(--ok-fg); } -.tl-note { color: var(--text-dim); font-style: italic; } -.drawer-section summary { cursor: pointer; color: #9fb4d6; font-weight: 600; - font-size: .85rem; } -.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - -/* --- Loop-back affordance on a node ------------------------------------- */ -.loop-chip { margin-left: .35rem; color: var(--accent); font-weight: 700; - font-size: .9rem; cursor: help; } -.badge.request_changes { background: var(--park-bg); color: var(--park-fg); } -.badge.approve, .badge.approved, .badge.pass { background: var(--ok-bg); color: var(--ok-fg); } - -/* --- Markdown content (Q&A / verdicts / plan) --------------------------- */ -.md { font-size: .82rem; color: var(--text); overflow-wrap: anywhere; } -.md > :first-child { margin-top: 0; } -.md > :last-child { margin-bottom: 0; } -.md p { margin: .4rem 0; } -.md ul, .md ol { margin: .4rem 0; padding-left: 1.2rem; } -.md li { margin: .15rem 0; } -.md strong { color: #fff; font-weight: 700; } -.md h1, .md h2, .md h3, .md h4 { font-size: .9rem; margin: .6rem 0 .3rem; - color: #9fb4d6; } -.md code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; - padding: 0 .25rem; font-size: .92em; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - padding: .5rem; overflow-x: auto; } -.md pre code { border: 0; padding: 0; background: none; } -.md a { color: var(--done-fg); } -.md blockquote { margin: .4rem 0; padding-left: .7rem; border-left: 2px solid var(--border-2); - color: var(--text-dim); } - -/* --- Q&A cards --------------------------------------------------------- */ -.qa-card, .verdict-card { background: var(--bg-2); border: 1px solid var(--border); - border-radius: 8px; padding: .55rem .65rem; margin-bottom: .6rem; } -.qa-turn { font-size: .72rem; font-weight: 700; color: var(--text-dim); - text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; } - -/* --- Verdict cards ----------------------------------------------------- */ -.verdict-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; - margin-bottom: .35rem; } -.verdict-round { font-size: .72rem; font-weight: 700; color: var(--text-dim); } -.verdict-outcome { font-size: .72rem; color: var(--text-dim); } - -/* --- Plan -------------------------------------------------------------- */ -.plan-phases { margin: .5rem 0 0; padding-left: 1.1rem; } -.plan-phase { margin-bottom: .5rem; } -.plan-phase-name { font-weight: 700; font-size: .84rem; } -.plan-steps { margin: .2rem 0 0; padding-left: 1.1rem; } -.plan-steps li { font-size: .8rem; margin: .15rem 0; } -.plan-extra { margin: .6rem 0 0; font-size: .78rem; } -.plan-extra-row { display: flex; gap: .5rem; } -.plan-extra dt { color: var(--text-dim); min-width: 5rem; } -.plan-extra dd { margin: 0; }