Skip to content

Latest commit

 

History

History
522 lines (350 loc) · 40.8 KB

File metadata and controls

522 lines (350 loc) · 40.8 KB

VR Module -- Frontend UX

What the researcher actually sees. Not "the platform has a UI" hand-waving -- the concrete pages, the data each page renders, the interactions that change state, the components used, and the platform contracts the module bolts into.

The VR module is a ModuleFrontendSpec like every other AILA module (frontend/src/platform/extension-registry/types.ts). It contributes nav entries, full-page routes, dashboard widgets, and detail panels. Nothing about the visual chrome is bespoke: it inherits AppShell, AppSidebar, PageFrame, the design tokens (bg-base, bg-surface, text-text, text-text-muted, border-border), and the Aila component family (AilaCard, AilaBadge, EmptyState, AilaTable, LoadingSkeleton, SeverityPulse).

The module's job is to put the right data on the right surface. This doc lays out what each surface is.

Cross-references:

  • docs/vr/01_REASONING_LOOP.md -- what the LLM emits per turn (drives the timeline page)
  • docs/vr/02_IDA_HEADLESS_MCP.md -- what produces the binary metadata the target page renders
  • docs/vr/03_EXPLOIT_AUTOMATION.md -- the obligation system the evidence graph visualises
  • docs/vr/04_MULTI_TARGET.md -- the project / target / campaign tree the dashboards walk
  • frontend/src/components/aila/ -- the design system the pages are built from
  • frontend/src/platform/extension-registry/types.ts -- the spec interface the module satisfies
  • src/aila/modules/forensics/frontend/ -- the closest existing module to copy patterns from

0. Preamble: What the UI Is Not

A common temptation when wiring a research module is to build a "researcher IDE" -- a pseudo-development environment with split panes, a code editor, a terminal, a custom file tree. We are explicitly not building that.

The researcher already has an IDE. They already have a terminal. They already have IDA open on a separate monitor. The VR frontend is the place where the LLM's reasoning, the platform's evidence, and the workflow state are visible together. Everything else lives in tools the researcher already trusts.

That decision narrows the surface area:

  • No code editor for harness source -- the harness is generated by the LLM, written to the workstation, and visible in the timeline turn that produced it. The researcher edits it on the workstation through their normal tools.
  • No terminal -- actions are typed, structured, and produce typed observations. There is no "free-form shell tab."
  • No file tree -- the project's filesystem is the workstation's filesystem, not a synced mirror.
  • No syntax-highlighted decompilation viewer -- IDA produces it, the LLM consumes it, the timeline shows what was used. If the researcher wants to read it, they open IDA.

What the UI does provide:

  • Live visibility into the loop (turn-by-turn reasoning).
  • Structured navigation across projects → targets → campaigns → findings.
  • Inspectable evidence (the graph, the obligations, the bounded packs).
  • Steering controls that change what the LLM sees next turn.
  • Triage surfaces (crash detail, exploit reliability, advisory editor) that close the research loop into something shippable.

That's the contract. Everything in this doc is in service of one of those five jobs.


0.bis Shipped frontend behaviours

The pages below describe the design intent. Three places where the live frontend has narrowed or refined the brainstorm:

0.bis.1 useInvestigationMessages -- opt-in live tail (commit ca1ff83)

useInvestigationMessages(investigationId, branchId?, offset?, limit?, opts?) defaults to a one-shot fetch -- refetchInterval is off by default. Pass { liveTail: true } to opt back into polling (default 3 s). Day-to-day live updates land via useInvestigationMessagesStream, an SSE client that subscribes to /vr/investigations/{id}/messages/stream?since_iso=<connect-time> and merges incoming VRMessageSummary rows into the same ["vr", "investigation-messages", ...] query cache. Result: every consumer of useInvestigationMessages sees new turns land as they happen, with zero polling cost on the API. The polling path is the explicit fallback when an operator view wants to keep auto-refreshing even when SSE is broken.

InvestigationDetailPage toggles a separate UI-level liveTail boolean to control auto-scroll-into-view on new messages; that toggle controls scroll behaviour, not refetch.

0.bis.2 PDF report export -- GET /vr/investigations/{id}/report.pdf

<ExportReportButton> (frontend/components/ExportReportButton.tsx) issues GET /vr/investigations/{investigation_id}/report.pdf and downloads the resulting blob. The backend runs the writer agent for prose synthesis plus a ReportLab renderer; the call typically takes 5–15 seconds, so the button shows a pending state and disables itself for the duration to prevent duplicate report jobs.

0.bis.3 Re-enqueue from InvestigationDetailPage

The <ReenqueuePicker> component renders next to the start/pause controls. It is visible only when inv.status === "completed" or inv.status === "failed". The component lets the operator pick a kind (discovery / nday / audit / variant_hunt / ...) and submits to POST /vr/investigations/{id}/re-enqueue, which re-runs run_vr_investigate with the new kind. Live (running / paused) investigations show the start / pause / reset triple instead.


1. Page inventory

Each page is a RouteContribution with slot: "page.full". Module routes are mounted under /vr/*. The breadcrumb on each page is set explicitly so the header doesn’t synthesize intermediate links to paths the module hasn’t registered.

The table below reflects what src/aila/modules/vr/frontend/routes.tsx actually exports as of 2026-06-21. Lazy-loaded pages (EvidenceGraphPage, ExploitEditorPage, NewProjectWizard, BranchTreePage) are flagged in the Notes column.

Route Component Min role Notes
/vr ProjectsPage reader Landing list of research engagements. nav: true.
/vr/projects/new NewProjectWizard operator Lazy. New-project intake wizard.
/vr/projects/:projectId ProjectDetailPage reader Per-engagement hub.
/vr/projects/:projectId/ndays/:cveId NdayPage reader N-day reproduction view.
/vr/projects/:projectId/findings/:findingId FindingDetailPage reader Project-scoped finding detail.
/vr/projects/:projectId/findings/:findingId/exploit ExploitEditorPage operator Lazy. PoC editor.
/vr/projects/:projectId/targets/:targetId TargetDetailPage reader Project-scoped alias for the flat target route.
/vr/projects/:projectId/campaigns/:campaignId FuzzCampaignDetailPage reader Project-scoped alias for the flat campaign route.
/vr/projects/:projectId/crashes/:crashId FuzzCrashDetailPage reader Project-scoped alias for the flat crash route.
/vr/projects/:projectId/timeline InvestigationsListPage reader Project-scoped timeline view (alias).
/vr/projects/:projectId/audit AuditLogPage admin Project-scoped audit view (alias).
/vr/investigations InvestigationsListPage reader Cross-project investigation list. nav: true.
/vr/investigations/:investigationId InvestigationDetailPage reader Investigation detail -- branch tabs + timeline + controls.
/vr/investigations/:investigationId/graph EvidenceGraphPage reader Lazy. ReactFlow evidence graph.
/vr/investigations/:investigationId/tree BranchTreePage reader Lazy. Branch structure visualisation.
/vr/workspaces WorkspacesPage reader Workspace switcher. nav: true.
/vr/targets TargetsPage reader Cross-project target list. nav: true.
/vr/targets/:targetId TargetDetailPage reader Target detail + analysis stages.
/vr/patterns PatternsPage reader Extracted vulnerability patterns. nav: true.
/vr/patterns/:patternId PatternDetailPage reader Pattern detail.
/vr/findings FindingsListPage reader Cross-project findings table. nav: true.
/vr/findings/:findingId FindingDetailPage reader Global finding detail.
/vr/disclosures DisclosuresPage operator Disclosure submissions. nav: true.
/vr/disclosures/:submissionId DisclosureDetailPage operator Disclosure detail + timeline.
/vr/fuzz/campaigns FuzzCampaignsPage reader Campaign list. nav: true.
/vr/fuzz/campaigns/:campaignId FuzzCampaignDetailPage reader Campaign detail.
/vr/fuzz/crashes/:crashId FuzzCrashDetailPage reader Crash triage.
/vr/mcp/servers McpServersPage admin MCP server health admin. nav: true.
/vr/mcp/calls McpCallLogPage admin MCP call log inspector. nav: true.
/vr/audit AuditLogPage admin VR-scoped audit log. nav: true.

Implementer note: re-read routes.tsx and add/remove rows before saving. The list above reflects the audit on 2026-06-21; the codebase may have moved since.

1.bis Investigation timeline behaviour (shipped)

A few page-level details the brainstorm body did not cover:

  • useInvestigationMessages now fetches up to 500 messages (was 100).
  • TurnCard collapses by default -- click the header to expand. Shows a one-line preview when collapsed.
  • Live updates flow exclusively through useInvestigationMessagesStream (SSE). The previous polling default in useInvestigationMessages is opt-in (liveTail: true option) after it contributed to two uvicorn OOMs in production. Existing positional-arg call sites remain compatible.

2. Key Interactions

The pages above are the what. This section is the how -- the live, mutable behaviours that distinguish this UI from a static dashboard.

2.1 Real-time reasoning stream

Project dashboard, target detail, timeline, and crash detail all subscribe to /api/vr/projects/:id/events via SSE. The platform already has the transport (platform/sse) and the frontend helper (streamJsonEvents). Events are typed:

type VRStreamEvent =
  | { type: "turn.started"; turn_id: string; target_id: string; action_type: string }
  | { type: "turn.completed"; turn_id: string; outcome: "ok" | "error"; duration_ms: number }
  | { type: "campaign.crash_found"; campaign_id: string; crash_id: string; bucket_new: boolean }
  | { type: "campaign.coverage_update"; campaign_id: string; edges: number; corpus_size: number }
  | { type: "exploit.test_completed"; exploit_id: string; run_id: string; outcome: string }
  | { type: "hypothesis.state_changed"; hypothesis_id: string; from: string; to: string }
  | { type: "obligation.resolved"; obligation_id: string; result: "satisfied" | "unsatisfied" }
  | { type: "operator.steering"; turn_id: string | null; action: string; operator_id: string };

Each subscriber listens for the subset it cares about. The timeline page listens for everything. The campaign dashboard listens for campaign.*. The crash detail page listens for campaign.crash_found (filtered by crash_id) and any turn.* whose payload references this crash.

This is one event stream per project, fanned out on the client. Server-side fan-out (one stream per page) was considered and rejected -- it multiplies connection count and complicates the audit log, for no UX gain. Clients are fine filtering.

Reconnection: standard streamJsonEvents retry with exponential backoff. On reconnect, the client sends ?since=<last_event_id> so it gets the missed events without a full state refetch. The server keeps a 5-minute event buffer per project.

Visual feedback that the stream is live:

  • A small "live" dot in the page header, green when connected, amber when reconnecting, red when disconnected for >10s.
  • New rows in the timeline animate in with a brief amber border flash (200ms transition on border-color, then back to neutral).
  • Crash buckets that just received a new sample pulse with SeverityPulse.

What happens when the stream is offline: every page still works -- they fall back to polling at 30s intervals. Live indicators turn amber and the "live tail" toggle disables. The researcher can still drive the UI manually.

2.2 Pause, inject, resume

The researcher hits "pause" in the steering drawer. Sequence of state changes:

  1. Frontend POSTs /api/vr/projects/:id/pause.
  2. The reasoning engine, which checks a pause flag at the top of every turn, sees it and stops scheduling new turns. In-flight turns finish (no killing mid-tool-execution -- that would corrupt evidence).
  3. The pause appears as an operator.steering event in the SSE stream; the timeline page renders an inline "paused by operator at turn 142 -- reason: " strip.
  4. Researcher types into "inject context" and clicks "submit." Frontend POSTs /api/vr/projects/:id/operator-context with the text and a target_id (optional -- global if omitted).
  5. Researcher clicks "resume." Frontend POSTs /api/vr/projects/:id/resume.
  6. The next turn picks up the operator context and includes it in the prompt as a labelled section. The ReasoningCaseState records that this turn was preceded by operator input -- adjudication later uses this when scoring whether a finding was operator-influenced.

What the LLM sees: a section in its prompt titled ## Operator Context (since last turn) with the text verbatim. Not paraphrased, not auto-translated into structured form. This is deliberate -- operator messages are short, domain-specific, and reframing them adds bugs.

What persists: the operator context is appended to the project's audit log. Future turns see the cumulative log only as compressed "recent operator notes" (last 3) -- the engine doesn't replay every operator note to every turn or the prompt explodes.

2.3 Hypothesis click → supporting and refuting evidence

In the evidence graph (§1.9), clicking a hypothesis node opens the right rail. The rail contains three lists:

  • Supports -- evidence nodes with supports edges in. Each row: evidence type icon, one-line summary, source turn link, weight (the adjudicator's score 0-1).
  • Refutes -- same shape, refutes edges in.
  • Open obligations -- obligation nodes with edges out to this hypothesis, in open state. Each row: claim, what would satisfy it, who-can-satisfy ("LLM" / "operator" / "tool: AFL++ + 2 hours of runtime").

The same rail appears anywhere a hypothesis is referenced: target detail's hypothesis tab, timeline turn cards' hypothesis-delta footer, crash detail's exploitability section. One component (<HypothesisDetailRail hypothesisId={…} />), reused.

Why this matters: the most common research mistake is treating a confirmed hypothesis as ground truth without checking which evidence supports it. If the only support is one decompilation excerpt, the hypothesis is fragile. The rail makes that visible -- sparse support shows up as a short list. A hypothesis with weight-0.9 support from five independent evidence types is qualitatively different from one with weight-0.3 support from one excerpt, and the UI surfaces that difference at the moment a researcher considers acting on it.

2.4 Crash → ASAN report → root cause → exploitability

This is the triage chain. The crash detail page (§1.6) renders it inline -- but the underlying content is built up turn by turn, and the page is just the read-out.

  1. Crash arrives from the campaign (campaign.crash_found event). The bucket either matches an existing one (deduped) or creates a new one. New buckets enter new state.
  2. The reasoning engine schedules a triage turn for new buckets above a priority threshold. Action: crash_triage with the crash id. Observation: a structured ASANReport if ASAN was on, else a structured Signal + StackTrace.
  3. A second turn -- decompile_function on the faulting function. Observation: pseudocode.
  4. A third -- data_flow_trace with source = "input bytes," sink = the dereferenced pointer. Observation: a chain of taint operations.
  5. A fourth -- hypothesis_create with a primitive label ("heap-buffer-overflow, write-controlled-len-from-input"). The hypothesis is now linked to the crash with a derived_from edge.
  6. A fifth -- exploitability_assess enumerates mitigation defeats required. Observation: a structured ExploitabilityAssessment with primitive type, preconditions, and a list of further obligations to validate exploitability.

Each turn appears in the crash detail page as a row in the "triage chain" section the moment it lands on the SSE stream. The current state of the crash is "where in this chain are we?" -- new → after step 2, triaging → after step 5, triaged → if step 6 succeeds, exploited → if a linked exploit's reliability passes the threshold. Failed steps move the bucket to dropped with a reason.

The page never computes the chain on render -- it walks an indexed list of turn → crash references stored on the crash row. Re-rendering is cheap.

2.5 Inline PoC editing

The exploit editor (§1.7) is a Monaco instance. Save semantics:

  1. Researcher edits, hits Cmd+S (or moves focus away -- auto-save on blur, debounced 1.5s).
  2. Frontend POSTs the file content to /api/vr/exploits/:id/source with an author=operator flag.
  3. Backend writes the file to the workstation (over the same SSH service the LLM uses). The version is tagged in the exploit's history with author + timestamp.
  4. A test run is triggered immediately. The "test runs" panel shows a new row in pending state.
  5. SSE stream emits exploit.test_completed. The row updates with outcome.
  6. If outcome is success and reliability stats cross the threshold, the exploit's status auto-updates from draft to passing. If outcome is crashed (the exploit crashed, not the target), status moves to broken.

Why auto-save and auto-test: in this domain the gap between "I changed the code" and "I see if it works" should be zero. Manual save buttons add a step that researchers skip, leading to "I edited but never ran" mistakes that show up as confused timeline entries.

What the LLM does with operator edits: the next turn that touches this exploit sees a diff snippet ("operator changed lines 47-52") and an updated lineage trace. It does not auto-revert operator changes, ever, even if its next action would have produced different code -- the LLM is a junior contributor on this file, not an owner.

Conflict resolution: if the LLM and operator both edit within the same 5-second window, the operator edit wins -- the LLM's change is rolled back to its pre-edit version and the loop is paused with a "operator edit during in-flight LLM edit" steering note. This is rare in practice (LLM edits are paced by the loop) but the rule must be explicit.

2.6 Live fuzzing dashboard

The campaign dashboard (§1.5) is the most visually-active page. Updates:

  • Coverage chart appends a new point every time a campaign.coverage_update event arrives (server emits at most one every 5s per campaign).
  • Crash list refreshes when a campaign.crash_found event arrives. New rows highlight for 3s.
  • Resource band polls workstation telemetry every 10s (not via SSE -- telemetry is high-volume and fine on a polling cadence).

A subtle UI rule: when the campaign is paused or stopped, the live indicators dim and the chart freezes (rather than continuing to extend a flat line, which would be misleading). When resumed, the chart shows a visible gap -- gaps in the data are real and shouldn't be smoothed away.


3. Evidence Graph Visualisation

This deserves its own section because it's the most technically distinctive part of the UI and the easiest place to get wrong.

3.1 The graph as a first-class artefact

The evidence graph is not a UI gimmick -- it is the project's epistemic state. Every hypothesis, every piece of evidence, every obligation, every crash, every exploit, every advisory is a node; every "this implies that" relationship is an edge. The backend stores it as nodes + edges in Postgres (see §4 and §6 of the multi-target doc). The UI renders it.

If the UI couldn't render it, the data would still be the source of truth; the UI just couldn't show it. That decoupling matters because it means:

  • The UI can change layout algorithms / interaction models without changing data.
  • Other consumers (advisory exports, CI checks, external dashboards) read the same graph.
  • A bug in the visualisation never corrupts the actual reasoning state.

3.2 Why ReactFlow

Considered alternatives:

  • d3-force directly -- total control, but every interaction (click, hover, drag, multi-select, edge routing) is ours to build. Three weeks of UI work before the first useful feature.
  • Cytoscape.js -- strong graph library, weaker React integration, custom node renderers awkward.
  • Graphviz on the server, render as SVG client-side -- non-interactive, not viable.
  • ReactFlow \u2014 already in package.json. React-native, custom nodes are JSX components, built-in pan/zoom/selection/edge routing. Well-maintained.

Decision: ReactFlow, with one caveat -- graphs above ~500 nodes start to lag. For projects that exceed that, the UI auto-clusters by target (each target collapses into a super-node; expanding it shows its sub-graph). The cluster threshold and the cluster behaviour are controlled by a feature flag so we can tune it once we have real engagements.

3.3 Node renderers

Each node type has a custom renderer. They share a base shell (<GraphNodeShell tone={…} icon={…}> -- using AilaCard padding="sm" internally) and differ in body content:

  • Hypothesis node: claim text (truncated to 140 chars), state pill, support / refute counts (+5 / -1 style).
  • Evidence node: evidence type icon, source ("decomp of parse_tlv" / "ASAN report" / "manual annotation"), excerpt preview on hover.
  • Crash node: signal/ASAN type icon, faulting function, severity pulse.
  • Exploit node: reliability bar (small), passing/flaky/broken state.
  • Advisory node: disclosure status, CVSS score.
  • Obligation node: claim, open/resolved icon, "what would satisfy" preview on hover.

All renderers use design tokens. No hex colours, no bg-[#…] arbitrary-value classes (Tailwind v4 doesn't generate CSS for those, per CLAUDE.md mistake #5).

3.4 Layout

Default: dagre left-to-right. Hypotheses cluster to the right of their evidence; crashes anchor on the bottom; advisories sit far right. The flow reads "evidence → hypothesis → crash → exploit → advisory."

Force-directed is offered for when dagre's hierarchy isn't useful (e.g. when a researcher wants to see clusters by topic rather than chains).

Manual is allowed and persisted per-user -- researchers who want to lay out a graph for a presentation can drag nodes and save. Manual layouts are scoped to (project_id, user_id) so one researcher's manual layout doesn't trip another's.

3.5 Performance

The risks:

  • Edge routing on 200+ nodes is expensive. Mitigation: precompute layout server-side for the initial render (return positions in the API response), client only re-layouts on filter changes.
  • Every event in the SSE stream updates the graph if a node changed. Mitigation: events update node attributes (state, weight) without retriggering layout; only structural changes (new nodes/edges) layout.
  • Hover preview popovers querying full node payloads for every node would be N requests. Mitigation: payload is included in the initial graph fetch, expanded on click, lazy on hover.

3.6 What clicking different things does

Click target Action
Hypothesis node Right rail shows hypothesis detail (§2.3).
Evidence node Right rail shows evidence: source turn, raw payload, all hypotheses it touches.
Crash node Right rail shows crash summary + "open crash detail" link.
Exploit node Right rail shows reliability stats + "open exploit editor" link.
Advisory node Right rail shows disclosure status + "open advisory" link.
Obligation node Right rail shows obligation claim + "what would satisfy" + "manually close" button (operator-only).
Edge Right rail shows edge type, source, target, weight, the turn that created it.
Background Right rail empty; a "select a node" hint.

Cmd-click on any node opens its dedicated page in a new tab (target detail / crash detail / exploit editor / etc.). This is the second-most-used graph interaction.


4. Platform Design System Integration

The module owns no CSS. Every visual decision routes through the platform.

4.1 Components used (and only these)

  • AilaCard -- every panel. Variants default, elevated, interactive (frontend/src/components/aila/AilaCard.tsx).
  • AilaBadge -- every status pill, severity tag, target-class label.
  • AilaTable -- projects list, crash list, function-of-interest table, exploit test runs.
  • EmptyState -- every "no data" surface (no projects, no crashes, no findings).
  • LoadingSkeleton -- every loading state (no spinners, no "Loading…" text).
  • SeverityPulse -- severity glow on findings/crash rows.
  • AilaChart -- every chart (coverage, crashes, corpus). Uses useThemeChartColors() because Recharts can't resolve var(--color-*) in SVG fill (CLAUDE.md mistake #4).
  • HelpTip -- inline explainers on the steering drawer, the obligation panel, and the CVSS calculator metrics.
  • PageTransition -- page-to-page transitions (used by PageFrame).

The module does not introduce new shared components. If it needs something genuinely new, it lifts the design to frontend/src/components/aila/ so other modules can use it. The first candidate is likely a <TurnCard> for the timeline page -- but only if forensics's TimelineViewer proves insufficient after a real engagement.

4.2 Tokens

Backgrounds: bg-base (page), bg-surface (cards), bg-elevated (raised cards), bg-surface-secondary (table row hover). Text: text-text (primary), text-text-muted (secondary), text-text-disabled (tertiary). Borders: border-border, border-border-hover. Tones (semantic): info, success, warning, critical, muted.

No raw colours. No bg-[#…]. No h-[720px] arbitrary values -- inline style={{ height: 720 }} if a literal pixel size is needed (CLAUDE.md mistake #5).

4.3 ModuleFrontendSpec

The module ships src/aila/modules/vr/frontend/spec.ts:

import type { ModuleFrontendSpec } from "@platform/extension-registry/types";
import { nav } from "./nav";
import { routes } from "./routes";
import { widgets } from "./widgets";
import { panels } from "./panels";

export const frontendSpec = {
  moduleId: "vr",
  nav,
  routes,
  widgets,
  panels,
} satisfies ModuleFrontendSpec;

nav contributes one entry: { id: "vr.projects", slot: "sidebar.main", label: "Vulnerability Research", to: "/vr", order: 70 } \u2014 placed after forensics (60).

routes lists the 12 page routes from §1, each with slot: "page.full" and an explicit breadcrumb (so the header doesn't synthesize phantom intermediate paths).

widgets contributes the four dashboard widgets in §5.

panels contributes (at minimum) a system.detail panel -- when the operator looks at a registered system in the platform's systems view, they see a "VR research on this target" panel listing any VR projects that include this system as a target. This is how the module discloses its findings to the platform-level system inventory without requiring researchers to manually link.

4.4 Lazy-loading

Following the vulnerability module's pattern, panel components and heavy graph code are lazy-loaded:

const EvidenceGraphPage = lazy(() => import("./screens/EvidenceGraphPage"));

ReactFlow's bundle size is non-trivial; users who never open the graph view shouldn't pay for it on the projects list.


5. Dashboard Widgets

The platform dashboard is the home page. Every module contributes widgets that summarise its state for an operator who isn't currently focused on it. The VR module contributes four:

5.1 Active research projects count

Card. Title: "Active Research." Body: a number (count of status=active projects) with a sublabel ("12 paused, 3 with errors"). Click → /vr filtered to status=active.

defaultSize: { w: 1, h: 1 }. Categorised under WidgetCategory.research (or whatever the platform's existing category vocabulary names it -- vulnerability uses security, forensics uses investigation, this is a new neighbour).

5.2 Total crashes found (with trend)

Card. Title: "Crashes Found." Body: total count across all projects in the operator's scope, with a 7-day trend mini-chart (sparkline using AilaChart). Sublabel: "+47 in the last 24 hours" (tinted green if more than yesterday, red if fewer -- for fuzzing campaigns, "fewer crashes today" usually means "the campaign got stuck," so it's not unambiguously good).

defaultSize: { w: 1, h: 1 }.

5.3 Exploitable findings count

Card. Title: "Exploitable." Body: count of findings with exploitability state confirmed. Severity breakdown sublabel: "2 critical, 5 high, 12 medium." Click → findings filtered.

defaultSize: { w: 1, h: 1 }.

5.4 Fuzzing coverage aggregate

Card. Title: "Fuzzing Coverage." Body: a stacked bar showing the fraction of bytes-of-binary covered, aggregated across all running campaigns. Sublabel: "23 campaigns running, 8 stable, 4 stuck."

"Stuck" is a derived metric (no new edges in the last 4 hours despite high exec/sec) and the widget surfaces it because that's the most actionable thing on the page -- a stuck campaign needs harness intervention.

defaultSize: { w: 2, h: 1 } -- wider because the bar needs room.

5.5 What's deliberately not a widget

  • "Latest reasoning turn" -- fascinating to a researcher mid-engagement, useless on the homepage. Lives on the project dashboard.
  • "Hypothesis count" -- a vanity metric. Open hypotheses are a normal part of operation, not a state to surveil.
  • "Evidence pack size" -- internal plumbing.

The four widgets above answer the only four homepage-level questions worth asking: what's running, what's been found, what's dangerous, and what's stuck. Anything beyond that belongs on a deeper page.


6. Cross-Cutting UX Concerns

6.1 Permissions and visibility

VR work is sensitive. Routes are gated by minRole: vr:viewer for read-only, vr:research for steering and editing, vr:disclosure for advisory authorship and disclosure-state changes. The role checks happen in the route contribution and are enforced server-side regardless. Sidebar nav respects minRole (a vr:viewer who can't run research still sees the projects list but not the steering drawer).

Within a project, operator-attribution is honoured: an operator who didn't author an exploit can read it but can't edit it. The "edit" affordance is hidden if disabled.

6.2 Audit trail surfacing

Every state-changing action (operator pause/resume, inject context, manual hypothesis confirm, exploit edit, advisory disclosure-state change) is auditable. The audit log is reachable from the project dashboard's overflow menu -- "Audit." It renders the same TurnCard shell as the timeline, but for operator events. Reading the timeline plus the audit log gives a complete picture of "what happened on this engagement, by whom, when."

6.3 Responsiveness

All pages are designed for ≥1280px width. The module is operator-facing, used at desks, not on mobile. Below 1280, columns stack and tables become horizontally scrollable; below 768, the evidence graph shows a "this view requires a wider window" message rather than rendering badly. This is consistent with the platform's stance that the operator console is desktop-first.

6.4 Error states

The same hierarchy as the rest of the platform:

  • Inline error in a card body for failed API calls (with retry button).
  • Toast (using the existing toast system) for failed mutations.
  • Full-page error boundary for crashes inside a route -- falls back to the platform error boundary, which already exists in app/error-boundaries/.

The SSE stream offline state is its own treatment (see §2.1) -- not an error per se, just a visible degradation of liveness.

6.5 Empty states

Every list has a real empty state with a primary action that gets the researcher unstuck:

  • No projects → "Start a project."
  • No targets in a project → "The intake is still extracting -- refresh in a minute."
  • No crashes in a campaign → "Campaign is just getting started" + a link to the harness for review.
  • No hypotheses on a target → "The LLM hasn't reasoned about this target yet" + "Run the first turn now" button.

EmptyState is the component for all of these. The "no hypotheses" copy specifically resists the temptation to be cute -- researchers want a button, not a joke.

6.6 Keyboard shortcuts

Three shortcuts, no more, all surface-level:

  • Cmd+P -- quick-jump search across the project (target / crash / exploit / hypothesis by id or fuzzy name).
  • Cmd+/ -- open the operator steering drawer on any page that has it.
  • J / K -- when the timeline page has focus, jump to next / previous turn.

We do not invent a new keyboard vocabulary. Heavy shortcut sets are an addiction in research tools and they create onboarding cost the module's audience won't pay back.

6.7 Accessibility

Standard WCAG-AA practices, which the platform's design tokens already meet. Specifics that bite this module:

  • Evidence graph nodes have aria-labels containing the node type and primary content (otherwise a screen reader sees only "node, node, node").
  • Coverage charts have a <table> fallback rendered with sr-only for the data series.
  • The reliability bar in the exploit editor announces a label like "passed 87 of 100 trials" rather than just colour.
  • The live-tail amber border-flash respects prefers-reduced-motion (no animation if reduced motion is on; the new row appears instantly with a static highlight that fades over a second).

AilaCard's motion primitives already use useReducedMotion (frontend/src/components/aila/AilaCard.tsx), so this is almost free.


7. Where the UI Talks to the Backend

For the avoidance of guessing, the module's frontend talks to the following endpoints (sketched, not exhaustive):

Endpoint Used by
GET /api/vr/projects §1.1 list
POST /api/vr/projects §1.2 wizard final submit
GET /api/vr/projects/:id §1.3 dashboard
GET /api/vr/projects/:id/targets §1.3 / §1.4
GET /api/vr/projects/:id/targets/:tid §1.4
GET /api/vr/projects/:id/campaigns §1.3 / §1.5
GET /api/vr/projects/:id/campaigns/:cid §1.5
GET /api/vr/projects/:id/crashes/:bid §1.6
GET /api/vr/projects/:id/exploits/:eid §1.7
POST /api/vr/exploits/:eid/source §2.5 inline edit
POST /api/vr/exploits/:eid/test §2.5 inline edit
GET /api/vr/projects/:id/advisories/:aid §1.8
GET /api/vr/projects/:id/graph §1.9
GET /api/vr/projects/:id/turns?since=… §1.10
GET /api/vr/projects/:id/events (SSE) §2.1
POST /api/vr/projects/:id/pause §1.12 / §2.2
POST /api/vr/projects/:id/resume §1.12 / §2.2
POST /api/vr/projects/:id/operator-context §1.12 / §2.2
POST /api/vr/projects/:id/hypotheses/:hid/operator-assert §1.12
POST /api/vr/projects/:id/obligations/:oid/operator-close §1.12
GET /api/vr/projects/:id/audit §6.2

Following the platform pattern, all of these are mounted under /api/vr/* by the module's api_router (deferred-imported per MODULE_STANDARD.md, registered through route_specs() -- not at module top level, which would trip the honesty audit).

The frontend uses TanStack Query with module-scoped keys (["vr", "projects", projectId, …]) so cache invalidation is precise. Mutations invalidate the relevant subtrees; SSE events invalidate at finer granularity (a turn.completed event touching ["vr", "projects", id, "turns"] only).


8. Open Questions

  1. Multi-operator real-time collaboration. Two researchers on the same project at once: do their pause/resume actions stack ("paused by both, must be resumed by both") or does last-action win? Operator culture in n-day work tends toward "one driver at a time" but open research often wants two pairs of eyes. We need to pick one and document it; the UI rendering of the other operator's presence (avatars on the steering drawer? cursor indicators on the timeline?) follows from the model.

  2. Graph at scale. A single project that's been running for weeks could produce 2,000+ evidence nodes. The cluster-by-target fallback is a sketch, not a design. Concretely: when do we cluster (>500 nodes? user-toggle? adaptive?), and what does an expanded cluster look like -- does it pop out into a side panel or expand inline? The performance work is real and we don't have measurements yet from a real engagement.

  3. Inline edit of harness source. The exploit editor allows inline edits because PoCs are short, often-edited, and the test loop is tight. Harnesses are different -- they're 100-500 lines of C, edited rarely, and a bad edit breaks a long-running campaign. Do we offer the same Monaco editor for harnesses, or only let the researcher edit them on the workstation through their own tools? The compromise (read-only view with a "request LLM to modify" button) is plausible but loses the immediacy of direct edit.

  4. Steering audit weight. Operator overrides flag as "yellow" -- but "yellow" means different things across reviewers. Should we instead surface a numerical "operator influence" score per finding (e.g. "3 operator overrides, 2 hypothesis-confirms") and let consumers decide their own threshold? This couples to advisory disclosure: high-operator-influence findings might need a different review path before publication.

  5. Cross-project graph view. A shared library exploited in product A might lead to a finding on product B. The project-level graph stops at project boundaries. Do we offer a tenant-level view ("all findings touching libfoo.so")? It requires a different mental model -- projects are isolation boundaries but bugs aren't -- and the UI to navigate it is genuinely different.

  6. Disclosure embargo enforcement in UI. An advisory under embargo must not leak through other surfaces (timeline turn cards reference the underlying crash; the crash references the exploit; the exploit references the advisory). We need a visibility-rule engine at the API level, not in the UI -- but the UI has to respect it gracefully (no broken links, no "you don't have access" walls of text). What's the rendering for "this turn's evidence is partially hidden because of embargo"?

  7. Harness regeneration UX. §1.5 has a "rebuild harness" button. What does the UX show during a rebuild -- a modal? A drawer? An inline progress section? The rebuild can take 30-90 seconds and involves multiple LLM turns; is the right pattern "open the timeline and watch" or "non-blocking with a notification when done"? Both are defensible; one ships first.

  8. N-day vs open-research view convergence. The two workflows (§1.11 vs §1.3-1.10) share most pages but differ in shape -- n-day has a fixed 4-stage progression, open research has none. Do we have a single project dashboard with a "mode" toggle, or two distinct dashboards with one route shared? Today's draft has them sharing -- but as the n-day workflow gains stage-specific affordances, that may stop being honest, and forcing them to converge would be exactly the kind of "one concept, one representation" violation the contract warns about.

  9. Operator-LLM edit conflicts on PoC code. §2.5 says "operator wins, loop pauses" for the rare overlapping-edit case. What if the edits don't conflict -- operator changed the trigger payload, LLM changed the post-exploitation stage? The cleanest answer is "merge if non-overlapping" but Monaco's text-merge isn't always trustworthy on shellcode. Do we degrade to "operator wins, LLM redoes its edit on the new base"?

  10. Mobile / tablet read-only view. Researchers do read findings from phones (on-call, in meetings). The desktop-only stance is right for steering, wrong for read-only review. Is there a separate, narrow read-only flow -- and if so, which pages does it cover? Probably: projects list, project dashboard summary, crash detail, advisory read-only. Probably not: timeline, evidence graph, exploit editor. But it's a separate UI surface to design, not a free responsive-CSS retrofit.

  11. Evidence pack visibility. The bounded packs the LLM consumes (§3 of the reasoning loop doc) are the precise inputs that produced each turn's reasoning. They're stored. Should the timeline expose them (a "show pack" expand on each TurnCard), or are they too noisy to be useful in the UI and only a debugging-tier surface? The trade-off is between transparency (every claim auditable) and signal (researchers don't want to read 8KB of decompilation per turn).

  12. What the platform's existing system-detail panel should show. §4.3 mentions a system.detail panel for VR. Concretely: which VR data is most useful when the operator is looking at a system in the platform inventory? "Open VR projects on this target" is one answer; "latest exploit reliability" is another; "current advisories under embargo" is a third. This needs a real conversation with operators who use the systems view today before we ship anything.