From 4fe9bcdacaad03650fd0e25c73594a839988af47 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 6 Aug 2026 01:48:47 +0200 Subject: [PATCH 01/13] feat(investigations): report-first detail page and fan-out hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An investigation is a finding, not a conversation. The Flue transcript was the page; now it is one tab behind the result. Detail page is tabbed — Overview · Evidence n · Hypotheses n · Chat · Transcript — with the header, status chips and actions persisting across all of them, and a one-line CAUSE recap strip so the detail tabs never lose context. Overview stays short and dense: verdict, impact strip, next actions, docked composer. Resolve and Retry moved to the header, beside the subject they act on. The rail drops to checks, run spine, linked records and provenance. The run is modelled as a fan-out: 1-5 diagnosing agents, each assigned a lens from a fixed catalogue, then a validator that promotes one candidate and records why each rival lost. That is the trust payload — it proves the obvious alternative was checked and says why it lost. Diagnosed runs get a "Hypotheses considered" table, live runs get parallel lens lanes, and failed runs get validation_inconclusive: lenses reported but nothing was promoted. Chat and Transcript are deliberately separate: Chat is the user's conversation, Transcript is the agents' own reasoning log, read-only. None of the fan-out is persisted yet. `V2Investigation` models a single pass — one report, one model, one token pair, no steps — so every lens, hypothesis, check and blast-radius value comes from `fanout-placeholder.ts`, a deterministic stub seeded off the investigation id (FNV-1a + LCG; no Math.random, no Date.now, because a lane that renumbered itself on every 3s poll would read as the run changing its mind). That module is the single seam: when the real records land it is the only file that dies. Everything else on the boards reads real wire fields, including several that ship today and rendered nowhere — incident window, related services, affected scope, and the error taxonomy that used to collapse into one generic toast. Two gaps worth knowing: v2 offers no stop endpoint, so a running pass shows Resolve rather than Stop; and the docked follow-up composer hands off to the Chat tab rather than owning a second session, because ChatConversation owns approvals and the failed-send queue. --- .../src/components/chat/chat-conversation.tsx | 36 +- .../src/components/chat/chat-transcript.tsx | 5 +- .../components/investigations/cause-recap.tsx | 25 + .../components/investigations/checks-rail.tsx | 82 +++ .../investigations/evidence-tab.tsx | 89 +++ .../investigations/fanout-placeholder.test.ts | 259 ++++++++ .../investigations/fanout-placeholder.ts | 598 ++++++++++++++++++ .../investigations/follow-up-composer.tsx | 31 + .../investigations/hypotheses-tab.tsx | 131 ++++ .../investigations/impact-strip.tsx | 147 +++++ .../investigations/investigate-bar.tsx | 58 ++ .../investigations/investigation-header.tsx | 133 ++++ .../investigations/investigation-rail.tsx | 231 +++---- .../investigations/investigation-table.tsx | 306 ++++----- .../investigations/investigation-tabs.tsx | 79 +++ .../investigations/investigation-view.tsx | 177 +++--- .../investigations/next-actions.tsx | 122 ++++ .../investigations/verdict-card.tsx | 508 +++++++++++++++ apps/web/src/routes/investigations/$id.tsx | 169 +++-- apps/web/src/routes/investigations/index.tsx | 365 ++++++++--- 20 files changed, 2957 insertions(+), 594 deletions(-) create mode 100644 apps/web/src/components/investigations/cause-recap.tsx create mode 100644 apps/web/src/components/investigations/checks-rail.tsx create mode 100644 apps/web/src/components/investigations/evidence-tab.tsx create mode 100644 apps/web/src/components/investigations/fanout-placeholder.test.ts create mode 100644 apps/web/src/components/investigations/fanout-placeholder.ts create mode 100644 apps/web/src/components/investigations/follow-up-composer.tsx create mode 100644 apps/web/src/components/investigations/hypotheses-tab.tsx create mode 100644 apps/web/src/components/investigations/impact-strip.tsx create mode 100644 apps/web/src/components/investigations/investigate-bar.tsx create mode 100644 apps/web/src/components/investigations/investigation-header.tsx create mode 100644 apps/web/src/components/investigations/investigation-tabs.tsx create mode 100644 apps/web/src/components/investigations/next-actions.tsx create mode 100644 apps/web/src/components/investigations/verdict-card.tsx diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index f66f0d20c..abba96799 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -31,8 +31,8 @@ import { PromptInputSubmit, } from "@/components/ai-elements/prompt-input" import { Suggestions, Suggestion } from "@/components/ai-elements/suggestion" -import { StatusMarker } from "@/components/ai-elements/status-marker" import { Button } from "@maple/ui/components/ui/button" +import { Spinner } from "@maple/ui/components/ui/spinner" import { trackProduct } from "@/lib/analytics" import { makeChatApplyPayload } from "./chat-apply-payload" import type { AiTriageResult } from "@maple/domain/http" @@ -53,7 +53,7 @@ interface ChatConversationProps { investigationContext?: InvestigationContext widgetFixContext?: WidgetFixContext /** Render the conversation with no composer, and say why. */ - readOnly?: false | "shared" | "resolved" + readOnly?: false | "shared" | "resolved" | "transcript" /** * The backend already seeded this conversation with its subject (an * investigation's autonomous pass sends the snapshot server-side), so the @@ -255,6 +255,11 @@ export function ChatConversation({ This investigation was resolved before anything was recorded. Reopen it to pick the thread back up. + ) : readOnly === "transcript" ? ( + + The agents' reasoning log appears here as the pass runs — every tool call and what + it returned. + ) : isInvestigationMode ? ( ) : isWidgetFixMode ? ( @@ -330,7 +335,16 @@ export function ChatConversation({ */ function InvestigationLead({ ctx }: { ctx: InvestigationContext }) { if (ctx.status === "investigating") { - return Gathering evidence… + // Not a `StatusMarker`: that is a full-width transcript row built to sit at + // the end of a thread, and the empty slot centres its child — so it stranded + // a left-aligned progress line in the middle of an otherwise blank pane. A + // thread with no turns yet is a state, and it reads like its three siblings. + return ( + + Maple is gathering evidence — the Transcript tab shows what it is doing as it goes. You can + ask a question here without waiting for it to finish. + + ) } if (ctx.status === "failed") { return ( @@ -366,10 +380,22 @@ function FailedSendNotice({ failed, onRetry }: { failed: FailedSend; onRetry: (t ) } -function EmptyNotice({ title, children }: { title: string; children: ReactNode }) { +function EmptyNotice({ + title, + busy = false, + children, +}: { + title: string + /** Something is still running behind this state — keeps the live signal. */ + busy?: boolean + children: ReactNode +}) { return (
-

{title}

+

+ {busy ? : null} + {title} +

{children}

) diff --git a/apps/web/src/components/chat/chat-transcript.tsx b/apps/web/src/components/chat/chat-transcript.tsx index b485598e5..cd508b252 100644 --- a/apps/web/src/components/chat/chat-transcript.tsx +++ b/apps/web/src/components/chat/chat-transcript.tsx @@ -227,7 +227,7 @@ export interface ChatTranscriptProps { focusMessageId?: string permalinkFor?: (messageId: string) => string /** Why the thread can't be replied to — the marker says which. */ - readOnly: false | "shared" | "resolved" + readOnly: false | "shared" | "resolved" | "transcript" emptyState: ReactNode } @@ -242,9 +242,10 @@ const isMachineTurn = (message: UIMessage): boolean => message.parts.every((part) => part.type === "text" && stripContextPreamble(part.text).length === 0) /** Leading marker for a thread that can't be continued. */ -const READ_ONLY_LABEL: Record<"shared" | "resolved", string> = { +const READ_ONLY_LABEL: Record<"shared" | "resolved" | "transcript", string> = { shared: "Shared conversation · read-only", resolved: "Investigation resolved · read-only", + transcript: "Agent reasoning log · read-only", } /** diff --git a/apps/web/src/components/investigations/cause-recap.tsx b/apps/web/src/components/investigations/cause-recap.tsx new file mode 100644 index 000000000..cf6c5e323 --- /dev/null +++ b/apps/web/src/components/investigations/cause-recap.tsx @@ -0,0 +1,25 @@ +import type { V2Investigation } from "@maple/domain/http/v2" + +/** + * One line of context so the detail tabs never lose the thread. Evidence and + * Hypotheses are both arguments *about* the cause, and reading either without + * the cause in view means scrolling back to Overview to remember what is being + * argued. + * + * Square on the left for the same reason the verdict card is — it carries the + * same accent rule. + */ +export function CauseRecap({ investigation }: { investigation: V2Investigation }) { + const cause = investigation.report?.suspectedCause?.trim() + if (!cause) return null + + return ( +
+ + + Cause + +

{cause}

+
+ ) +} diff --git a/apps/web/src/components/investigations/checks-rail.tsx b/apps/web/src/components/investigations/checks-rail.tsx new file mode 100644 index 000000000..9172a9f5c --- /dev/null +++ b/apps/web/src/components/investigations/checks-rail.tsx @@ -0,0 +1,82 @@ +import type { V2Investigation } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" + +import { CheckIcon, XmarkIcon } from "@/components/icons" +import { type CheckState, checksHeld, placeholderChecks } from "./fanout-placeholder" + +/** + * The rail's lead, and the first thing anyone reads on the page: one line per + * dispatched lens, with a ✓ / ✗ / ○ and a terse result. + * + * A failed check is not a gap — "callee percentiles flat, healthy" is a finding, + * and it is what rules the obvious alternative out. That is why the header counts + * how many *held* rather than how many ran. + */ +export function ChecksRail({ investigation }: { investigation: V2Investigation }) { + const checks = placeholderChecks(investigation) + // A single-agent run has no lens results to summarise, so the rail leads with + // the run spine instead of a panel of invented ticks. + if (checks.length === 0) return null + const held = checksHeld(checks) + // "so far" is only honest while something is still running. A skipped check on + // a dead pass is settled — it is never going to report. + const pending = checks.filter((check) => check.state === "checking" || check.state === "queued").length + + return ( +
+
+

+ Checks +

+ + {held} of {checks.length} {pending === 0 ? "held" : "so far"} + +
+
    + {checks.map((check) => ( +
  • + + + + + + {check.label} + + + {check.result} + + +
  • + ))} +
+
+ ) +} + +function CheckGlyph({ state }: { state: CheckState }) { + switch (state) { + case "held": + return + case "failed": + return + case "checking": + return + default: + return ( + + ) + } +} diff --git a/apps/web/src/components/investigations/evidence-tab.tsx b/apps/web/src/components/investigations/evidence-tab.tsx new file mode 100644 index 000000000..0accc6a69 --- /dev/null +++ b/apps/web/src/components/investigations/evidence-tab.tsx @@ -0,0 +1,89 @@ +import { Link } from "@tanstack/react-router" +import type { V2Investigation } from "@maple/domain/http/v2" + +import { CauseRecap } from "./cause-recap" + +/** + * The findings that back the cause, promoted out of the chat transcript where + * they used to live inside a card inside a scroll. Each finding is numbered and + * carries its own citations — the trace chips are real links, which is the whole + * reason to give evidence a tab of its own rather than a paragraph. + */ +export function EvidenceTab({ investigation }: { investigation: V2Investigation }) { + const evidence = (investigation.report?.evidence ?? []).filter( + (item) => item.note || item.traceIds.length > 0 || item.logPatterns.length > 0, + ) + + if (evidence.length === 0) { + return ( +
+ +

+ {investigation.status === "investigating" + ? "Evidence appears here as the lenses report." + : "This pass recorded no evidence."} +

+
+ ) + } + + const traceCount = evidence.reduce((total, item) => total + item.traceIds.length, 0) + + return ( +
+ +
+
+

+ Evidence +

+ + {evidence.length} {evidence.length === 1 ? "finding" : "findings"} + {traceCount > 0 ? ` · ${traceCount} ${traceCount === 1 ? "trace" : "traces"}` : ""} + +
+
    + {evidence.map((item, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + +
    + {item.note ? ( +

    {item.note}

    + ) : null} + {item.traceIds.length > 0 || item.logPatterns.length > 0 ? ( +
    + {item.traceIds.map((traceId) => ( + + {traceId.slice(0, 12)} + {traceId.length > 12 ? "…" : ""} + + ))} + {item.logPatterns.map((pattern) => ( + + {pattern} + + ))} +
    + ) : null} +
    +
  2. + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/investigations/fanout-placeholder.test.ts b/apps/web/src/components/investigations/fanout-placeholder.test.ts new file mode 100644 index 000000000..cb2adc371 --- /dev/null +++ b/apps/web/src/components/investigations/fanout-placeholder.test.ts @@ -0,0 +1,259 @@ +import type { V2Investigation } from "@maple/domain/http/v2" +import { describe, expect, it } from "vitest" + +import { + LENS_CATALOGUE, + checksHeld, + fanoutSize, + hasFanout, + lensTally, + placeholderBlastRadius, + placeholderChecks, + placeholderLenses, + placeholderRunSteps, + placeholderValidator, +} from "./fanout-placeholder" + +const make = (overrides: Partial = {}): V2Investigation => + ({ + id: "inv_1", + object: "investigation", + status: "diagnosed", + subject: { type: "incident", incident_kind: "error", incident_id: "einc_1", issue_id: null }, + snapshot: { + title: "Checkout timeouts after deploy 8f21c", + scope: "checkout-api", + status: "open", + severity: "critical", + facts: [], + references: [], + incidentStartedAt: null, + incidentEndedAt: null, + }, + report: null, + model: null, + severity: "critical", + confidence: "high", + seeded_by: "system", + created_by: null, + input_tokens: null, + output_tokens: null, + error: null, + created_at: "2026-07-20T09:00:00.000Z", + diagnosed_at: "2026-07-20T09:00:38.000Z", + updated_at: "2026-07-20T09:00:38.000Z", + ...overrides, + }) as V2Investigation + +/** + * The whole point of the module: a lane that renumbered itself on every 3s poll + * would read as the run changing its mind. Nothing here may depend on wall clock + * or `Math.random`. + */ +describe("determinism", () => { + it("returns identical lanes for the same investigation", () => { + const investigation = make() + expect(placeholderLenses(investigation)).toEqual(placeholderLenses(investigation)) + expect(placeholderChecks(investigation)).toEqual(placeholderChecks(investigation)) + expect(placeholderBlastRadius(investigation)).toEqual(placeholderBlastRadius(investigation)) + }) + + it("differs between investigations, or every page would look the same", () => { + const a = placeholderBlastRadius(make({ id: "inv_aaa" } as Partial)) + const b = placeholderBlastRadius(make({ id: "inv_zzz" } as Partial)) + expect(a).not.toEqual(b) + }) +}) + +describe("fanoutSize", () => { + it("gives a freeform question one agent and no fan-out", () => { + const investigation = make({ + subject: { type: "freeform", title: "why slow", prompt: "why slow", context_refs: [] }, + } as Partial) + expect(fanoutSize(investigation)).toBe(1) + expect(hasFanout(investigation)).toBe(false) + }) + + it("scales an alert to the full catalogue", () => { + const investigation = make({ + subject: { type: "incident", incident_kind: "alert", incident_id: "inc_1", issue_id: null }, + } as Partial) + expect(fanoutSize(investigation)).toBe(LENS_CATALOGUE.length) + }) + + it("scales an error incident by severity", () => { + expect(fanoutSize(make({ severity: "critical" } as Partial))).toBe(5) + expect(fanoutSize(make({ severity: "high" } as Partial))).toBe(4) + expect(fanoutSize(make({ severity: "medium" } as Partial))).toBe(3) + expect(fanoutSize(make({ severity: "low" } as Partial))).toBe(2) + }) + + it("caps an anomaly at two regardless of severity", () => { + const investigation = make({ + subject: { type: "incident", incident_kind: "anomaly", incident_id: "anom_1", issue_id: null }, + severity: "critical", + } as Partial) + expect(fanoutSize(investigation)).toBe(2) + }) + + it("never dispatches more lenses than the catalogue holds", () => { + expect(placeholderLenses(make())).toHaveLength(LENS_CATALOGUE.length) + }) +}) + +describe("placeholderLenses", () => { + it("promotes exactly one candidate on a diagnosed run", () => { + const tally = lensTally(placeholderLenses(make())) + expect(tally.promoted).toBe(1) + expect(tally.merged).toBeLessThanOrEqual(1) + expect(tally.promoted + tally.merged + tally.ruledOut).toBe(tally.total) + }) + + it("states the real diagnosis on the promoted lane rather than a template", () => { + const investigation = make({ + report: { + summary: "s", + suspectedCause: "Connection pool exhaustion in checkout-api", + severityAssessment: "critical", + affectedScope: "checkout", + evidence: [], + suggestedActions: [], + confidence: "high", + }, + } as unknown as Partial) + const promoted = placeholderLenses(investigation).find((lens) => lens.verdict === "promoted") + expect(promoted?.claim).toBe("Connection pool exhaustion in checkout-api") + }) + + it("leaves at least one lens unfinished while investigating", () => { + const lenses = placeholderLenses(make({ status: "investigating" })) + expect(lenses.some((lens) => lens.status !== "reported")).toBe(true) + expect(lenses.every((lens) => lens.verdict === "pending")).toBe(true) + }) + + it("rejects every candidate on a failed run", () => { + const lenses = placeholderLenses(make({ status: "failed" })) + expect(lenses.every((lens) => lens.verdict === "rejected")).toBe(true) + expect(lensTally(lenses).promoted).toBe(0) + }) + + it("gives every rejected lane a reason — a verdict without one proves nothing", () => { + for (const status of ["diagnosed", "failed"] as const) { + for (const lens of placeholderLenses(make({ status }))) { + if (lens.verdict === "promoted" || lens.verdict === "pending") continue + expect(lens.reason).toBeTruthy() + } + } + }) +}) + +describe("placeholderValidator", () => { + it("is absent when there is nothing to compare", () => { + const investigation = make({ + subject: { type: "freeform", title: "q", prompt: "q", context_refs: [] }, + } as Partial) + expect(placeholderValidator(investigation)).toBeNull() + }) + + it("is blocked while lenses are still reporting", () => { + expect(placeholderValidator(make({ status: "investigating" }))?.status).toBe("blocked") + }) + + it("rejects all on a failed run", () => { + expect(placeholderValidator(make({ status: "failed" }))?.status).toBe("rejected_all") + }) +}) + +describe("placeholderChecks", () => { + it("runs one check per dispatched lens", () => { + for (const status of ["investigating", "diagnosed", "failed", "resolved"] as const) { + const investigation = make({ status }) + expect(placeholderChecks(investigation)).toHaveLength(fanoutSize(investigation)) + } + }) + + it("drops out entirely at a fan-out of one, like the rest of the fan-out UI", () => { + const investigation = make({ + subject: { type: "freeform", title: "q", prompt: "q", context_refs: [] }, + } as Partial) + expect(placeholderChecks(investigation)).toEqual([]) + }) + + /** + * The regression this file exists to prevent: the rail used to generate its own + * verdicts, so a run whose validator rejected every candidate still showed three + * green ticks 300px away from "none of them held up". + */ + it("holds nothing when the validator promoted nothing", () => { + const checks = placeholderChecks(make({ status: "failed" })) + expect(checksHeld(checks)).toBe(0) + }) + + it("holds exactly the lenses the validator kept", () => { + const investigation = make() + const lenses = placeholderLenses(investigation) + const kept = lenses.filter((lane) => lane.verdict === "promoted" || lane.verdict === "merged").length + expect(checksHeld(placeholderChecks(investigation))).toBe(kept) + }) + + it("labels each check with the lens it summarises", () => { + const investigation = make() + expect(placeholderChecks(investigation).map((check) => check.key)).toEqual( + placeholderLenses(investigation).map((lane) => lane.lens.id), + ) + }) + + it("leaves later checks unsettled while investigating", () => { + const checks = placeholderChecks(make({ status: "investigating" })) + expect(checks.some((check) => check.state === "checking")).toBe(true) + }) + + it("only blames the tool budget on a lens that actually ran out", () => { + for (const status of ["investigating", "diagnosed", "resolved"] as const) { + for (const check of placeholderChecks(make({ status }))) { + expect(check.result).not.toContain("ran out of budget") + } + } + }) + + it("never renders an empty result line", () => { + for (const status of ["investigating", "diagnosed", "failed", "resolved"] as const) { + for (const check of placeholderChecks(make({ status }))) { + expect(check.result.length).toBeGreaterThan(0) + } + } + }) +}) + +describe("placeholderRunSteps", () => { + it("drops out entirely at a fan-out of one", () => { + const investigation = make({ + subject: { type: "freeform", title: "q", prompt: "q", context_refs: [] }, + } as Partial) + expect(placeholderRunSteps(investigation)).toEqual([]) + }) + + it("ends on validation for a diagnosed run", () => { + const steps = placeholderRunSteps(make()) + expect(steps.at(-1)?.key).toBe("validated") + }) + + it("ends on inconclusive validation for a failed run", () => { + const steps = placeholderRunSteps(make({ status: "failed" })) + expect(steps.at(-1)?.key).toBe("inconclusive") + expect(steps.at(-1)?.tone).toBe("failed") + }) + + it("reports partial progress while investigating", () => { + const steps = placeholderRunSteps(make({ status: "investigating" })) + expect(steps.at(-1)?.tone).toBe("active") + }) +}) + +describe("placeholderBlastRadius", () => { + it("keeps affected users under total events", () => { + const { events, users } = placeholderBlastRadius(make()) + expect(users).toBeGreaterThan(0) + expect(users).toBeLessThan(events) + }) +}) diff --git a/apps/web/src/components/investigations/fanout-placeholder.ts b/apps/web/src/components/investigations/fanout-placeholder.ts new file mode 100644 index 000000000..e02db0209 --- /dev/null +++ b/apps/web/src/components/investigations/fanout-placeholder.ts @@ -0,0 +1,598 @@ +/** + * PLACEHOLDER — none of this is real data. + * + * The investigations redesign is built around a fan-out run: 1–5 diagnosing + * agents, each assigned a distinct *lens*, then a validator that promotes one + * candidate and records why each rival lost. Nothing persists that today. + * `packages/db/src/schema/investigations.ts` is one flat row and the v2 wire + * carries a single `report` blob, one model, one token pair, and no steps — so + * the lens lanes, the "Hypotheses considered" table, the checks panel and the + * fan-out segment of the run spine have no source to read from. + * + * Everything here is derived from the investigation's `id` (stable across + * refreshes — a lane that renumbered itself every 3s poll would be worse than + * no lane) and from its `status`, so the investigating / diagnosed / failed + * boards each render their real variant instead of one frozen mock. + * + * When the fan-out is persisted, this module is the only file that has to die: + * replace these functions with reads off the wire and every consumer keeps its + * shape. Nothing else in `components/investigations/` invents data. + * + * No `Math.random`, no `Date.now` — a placeholder that changes under the user + * is a bug report waiting to happen. + */ + +import type { V2Investigation } from "@maple/domain/http/v2" + +/* ------------------------------------------------------------------------------------------------- + * Lens catalogue + * -----------------------------------------------------------------------------------------------*/ + +export type LensId = + | "deploy_correlation" + | "downstream_dependency" + | "resource_saturation" + | "traffic_shape" + | "config_flags" + +export interface Lens { + readonly id: LensId + readonly name: string + /** What this lens is actually asking — shown as the catalogue's one-liner. */ + readonly question: string +} + +/** + * Fixed, and deliberately so: lenses are picked, not invented per run, which is + * what keeps "ruled out" comparable across two investigations. A lens that finds + * nothing is still a result worth printing. + * + * Order is dispatch order — the fan-out takes the first N. + */ +export const LENS_CATALOGUE: ReadonlyArray = [ + { + id: "deploy_correlation", + name: "Deploy correlation", + question: "What shipped before the window, and does the onset line up", + }, + { + id: "downstream_dependency", + name: "Downstream dependency", + question: "Is a callee actually degraded, or only being blamed", + }, + { + id: "resource_saturation", + name: "Resource saturation", + question: "Pools, queues, memory, connections at a ceiling", + }, + { + id: "traffic_shape", + name: "Traffic shape", + question: "Volume and mix against the 7-day baseline for that hour", + }, + { + id: "config_flags", + name: "Config & flags", + question: "Flag flips and config writes inside the window", + }, +] + +/* ------------------------------------------------------------------------------------------------- + * Deterministic seeding + * -----------------------------------------------------------------------------------------------*/ + +/** FNV-1a. Small, no dependency, and well spread over short id strings. */ +const hashSeed = (value: string): number => { + let hash = 0x811c9dc5 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) + } + return hash >>> 0 +} + +/** + * A tiny LCG. Callers pull a fresh sequence per concern (`lenses`, `checks`, …) + * so adding a field to one section can't shift the numbers in another. + */ +const rng = (seed: number) => { + let state = seed || 1 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const seedFor = (investigation: V2Investigation, concern: string) => + rng(hashSeed(`${investigation.id}:${concern}`)) + +/** Inclusive integer in [min, max]. */ +const between = (next: () => number, min: number, max: number) => min + Math.floor(next() * (max - min + 1)) + +/* ------------------------------------------------------------------------------------------------- + * Fan-out sizing + * -----------------------------------------------------------------------------------------------*/ + +/** + * Severity × signal kind, straight off the design's sizing table. A freeform + * question gets one agent and no validator — there is nothing to compare — and + * every consumer treats `1` as "collapse the fan-out UI entirely". + */ +export function fanoutSize(investigation: V2Investigation): number { + if (investigation.subject.type === "freeform") return 1 + const kind = investigation.subject.incident_kind + if (kind === "alert") return 5 + if (kind === "anomaly") return 2 + switch (investigation.severity ?? investigation.snapshot.severity) { + case "critical": + return 5 + case "high": + return 4 + case "medium": + return 3 + case "low": + return 2 + default: + return 3 + } +} + +/** At one agent there are no rivals, so there is nothing to rank or rule out. */ +export const hasFanout = (investigation: V2Investigation): boolean => fanoutSize(investigation) > 1 + +/* ------------------------------------------------------------------------------------------------- + * Lens lanes + * -----------------------------------------------------------------------------------------------*/ + +/** Where the lens itself got to, independent of what the validator made of it. */ +export type LensRunStatus = "reported" | "checking" | "queued" | "no_finding" + +/** What the validator did with the lens's candidate. `pending` = not ranked yet. */ +export type LensVerdict = "promoted" | "merged" | "ruled_out" | "rejected" | "pending" + +export interface PlaceholderLens { + readonly lens: Lens + readonly status: LensRunStatus + readonly verdict: LensVerdict + /** The candidate cause this lens put forward. */ + readonly claim: string | null + /** The validator's one-line reason — the trust payload of the whole section. */ + readonly reason: string | null + /** What it is doing right now, while `status` is `checking`. */ + readonly progressNote: string | null + readonly elapsedSeconds: number | null + readonly toolCount: number + readonly confidence: "high" | "medium" | "low" | null +} + +/** The validator lane — present whenever the run fanned out at all. */ +export interface PlaceholderValidator { + readonly status: "blocked" | "ranked" | "rejected_all" + readonly note: string + readonly elapsedSeconds: number | null +} + +/** + * The lens's own subject line. The scope is woven in so two investigations don't + * read identically, but the sentence is still a template — it is not a finding. + */ +const CLAIM: Record string> = { + deploy_correlation: (scope) => `A deploy to ${scope} lands just before the onset of the window`, + downstream_dependency: (scope) => `A dependency of ${scope} degrades and propagates upstream`, + resource_saturation: (scope) => `${scope} is pinned against a pool or queue ceiling`, + traffic_shape: (scope) => `A volume shift overwhelms the capacity ${scope} was sized for`, + config_flags: (scope) => `A flag or config write inside the window changes how ${scope} behaves`, +} + +const PROMOTED_REASON = + "strongest causal chain — the only candidate that explains both the onset delay and the shape of the recovery." +const MERGED_REASON = + "same mechanism seen from the other end — folded into the promoted cause as evidence rather than kept as a rival." +const RULED_OUT_REASON: Record = { + deploy_correlation: "no deploy landed inside the window, so nothing shipped can explain the onset.", + downstream_dependency: "contradicted by evidence — the callee stayed flat across the whole window.", + resource_saturation: "utilisation sat well under its ceiling for the duration.", + traffic_shape: + "request volume sits within a few percent of the 7-day baseline for this hour — nothing load-driven to explain.", + config_flags: "no flag flip or config write is recorded inside the window.", +} + +const CHECKING_NOTE: Record = { + deploy_correlation: "correlating the deploy timeline", + downstream_dependency: "comparing downstream percentiles", + resource_saturation: "pulling pool and queue gauges", + traffic_shape: "pulling the 7-day volume baseline", + config_flags: "diffing config inside the window", +} + +const scopeWord = (investigation: V2Investigation): string => + investigation.snapshot.scope?.trim().split(/\s+/)[0] ?? "this service" + +/** + * One lane per dispatched lens, shaped by status: + * + * - `investigating` — a prefix has reported, one is checking, the rest queued. + * - `diagnosed` — the first lens is promoted, at most one merged, rest ruled out. + * - `failed` — every lens is rejected (the `validation_inconclusive` variant), + * with one lens having run out of budget before it found anything. + * - `resolved` — same as diagnosed; resolving doesn't rewrite what was found. + */ +export function placeholderLenses(investigation: V2Investigation): ReadonlyArray { + const size = fanoutSize(investigation) + const next = seedFor(investigation, "lenses") + const scope = scopeWord(investigation) + const dispatched = LENS_CATALOGUE.slice(0, size) + const status = investigation.status + + // How far through the fan-out a live run is. Always leaves at least one lens + // unfinished, or "investigating" would render as a completed board. + const reportedCount = status === "investigating" ? between(next, 1, Math.max(1, size - 1)) : size + // Which lens the validator folded into the promoted one. -1 = none merged. + const mergedIndex = size > 2 && next() > 0.45 ? 1 : -1 + // On a failed run, the lens that never got to a finding at all. + const starvedIndex = status === "failed" ? size - 1 : -1 + + return dispatched.map((lens, index) => { + const elapsed = Math.round((4 + next() * 11) * 10) / 10 + const toolCount = between(next, 2, 5) + const claim = CLAIM[lens.id](scope) + + if (status === "investigating") { + if (index < reportedCount) { + return { + lens, + status: "reported", + verdict: "pending", + claim, + reason: null, + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: null, + } + } + if (index === reportedCount) { + return { + lens, + status: "checking", + verdict: "pending", + claim: null, + reason: null, + progressNote: CHECKING_NOTE[lens.id], + elapsedSeconds: Math.round(elapsed * 0.4 * 10) / 10, + toolCount: 1, + confidence: null, + } + } + return { + lens, + status: "queued", + verdict: "pending", + claim: null, + reason: null, + progressNote: null, + elapsedSeconds: null, + toolCount: 0, + confidence: null, + } + } + + if (status === "failed") { + if (index === starvedIndex) { + return { + lens, + status: "no_finding", + verdict: "rejected", + claim: null, + reason: "ran out of tool budget before it could reach a finding.", + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: null, + } + } + return { + lens, + status: "reported", + verdict: "rejected", + claim, + reason: "contradicted by at least one other lens, and it did not explain the onset delay.", + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: null, + } + } + + // diagnosed / resolved + if (index === 0) { + return { + lens, + status: "reported", + verdict: "promoted", + // The promoted lane states the real diagnosis, not a template — this + // one field is genuine. + claim: investigation.report?.suspectedCause ?? claim, + reason: PROMOTED_REASON, + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: investigation.report?.confidence ?? investigation.confidence ?? "medium", + } + } + if (index === mergedIndex) { + return { + lens, + status: "reported", + verdict: "merged", + claim, + reason: MERGED_REASON, + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: "medium", + } + } + return { + lens, + status: "reported", + verdict: "ruled_out", + claim, + reason: RULED_OUT_REASON[lens.id], + progressNote: null, + elapsedSeconds: elapsed, + toolCount, + confidence: index % 2 === 0 ? "low" : "medium", + } + }) +} + +/** Tallies the lanes the way the run spine and the section headers quote them. */ +export function lensTally(lenses: ReadonlyArray) { + return { + total: lenses.length, + reported: lenses.filter((entry) => entry.status === "reported").length, + promoted: lenses.filter((entry) => entry.verdict === "promoted").length, + merged: lenses.filter((entry) => entry.verdict === "merged").length, + ruledOut: lenses.filter((entry) => entry.verdict === "ruled_out").length, + rejected: lenses.filter((entry) => entry.verdict === "rejected").length, + } +} + +export function placeholderValidator(investigation: V2Investigation): PlaceholderValidator | null { + if (!hasFanout(investigation)) return null + const lenses = placeholderLenses(investigation) + const tally = lensTally(lenses) + if (investigation.status === "investigating") { + return { + status: "blocked", + note: `Starts once all ${tally.total} lenses report — then ranks the candidates and promotes one`, + elapsedSeconds: null, + } + } + if (investigation.status === "failed") { + return { + status: "rejected_all", + note: `No candidate survived: each was contradicted by at least one other lens`, + elapsedSeconds: 8.2, + } + } + return { + status: "ranked", + note: `${tally.promoted} promoted · ${tally.merged} merged · ${tally.ruledOut} ruled out`, + elapsedSeconds: 8.2, + } +} + +/* ------------------------------------------------------------------------------------------------- + * Checks + * -----------------------------------------------------------------------------------------------*/ + +export type CheckState = "held" | "failed" | "checking" | "queued" | "skipped" + +export interface PlaceholderCheck { + readonly key: string + readonly label: string + /** The terse result line under the label — the whole point of the panel. */ + readonly result: string + readonly state: CheckState +} + +/** + * One shape per lens, keyed by lens id rather than positionally: the checks panel + * *is* the lens results in one line each, so a check that could outlive its lens — + * or land in a different order than the fan-out dispatched — would be inventing a + * second, contradicting account of the same run. + */ +const CHECK_SHAPES: Record< + LensId, + { + readonly label: string + readonly held: string + readonly failed: string + readonly checking: string + readonly starved: string + } +> = { + deploy_correlation: { + label: "Deploy in the window", + held: "a rollout lands minutes before onset", + failed: "nothing shipped inside the window", + checking: "correlating the deploy timeline…", + starved: "not checked — lens ran out of budget", + }, + downstream_dependency: { + label: "Downstream latency", + held: "callee percentiles climb with the caller", + failed: "callee percentiles flat — healthy", + checking: "comparing percentiles…", + starved: "not checked — lens ran out of budget", + }, + resource_saturation: { + label: "Connection pool headroom", + held: "at the ceiling for the duration", + failed: "well under the ceiling throughout", + checking: "pulling pool gauges…", + starved: "not checked — lens ran out of budget", + }, + traffic_shape: { + label: "Request volume", + held: "well above the 7-day baseline", + failed: "within a few percent of the 7-day baseline", + checking: "pulling the baseline…", + starved: "not checked — lens ran out of budget", + }, + config_flags: { + label: "Client agent config", + held: "changed inside the window", + failed: "unchanged across the window", + checking: "diffing config…", + starved: "not checked — lens ran out of budget", + }, +} + +/** + * The rail's lead: one line per dispatched lens, each with a ✓ / ✗ / ○ and a terse + * result. `n of m held` is what someone reads before anything else on the page, so + * a check that *fails* still has to say something useful — "flat at baseline" is a + * finding, not a blank. + * + * Every row is derived from the lens lane it summarises, which is what keeps the + * rail and the board from disagreeing: a validator that rejected everything cannot + * leave three ticks standing in the rail. At a fan-out of one there are no lens + * results to summarise, so the panel drops out entirely — the same way the run + * spine's fan-out segment and the validator lane already do. + */ +export function placeholderChecks(investigation: V2Investigation): ReadonlyArray { + if (!hasFanout(investigation)) return [] + const next = seedFor(investigation, "checks") + + return placeholderLenses(investigation).map((lane) => { + const shape = CHECK_SHAPES[lane.lens.id] + const base = { key: lane.lens.id, label: shape.label } + + switch (lane.status) { + case "checking": + return { ...base, result: shape.checking, state: "checking" as const } + case "queued": + return { ...base, result: "queued", state: "queued" as const } + case "no_finding": + return { ...base, result: shape.starved, state: "skipped" as const } + default: { + // A lens that has reported but not yet been ranked still made an + // observation — the validator decides whether it *explains* anything, + // not whether it happened. Seeded so a 3s poll can't flip it. + const held = + lane.verdict === "pending" + ? next() > 0.5 + : lane.verdict === "promoted" || lane.verdict === "merged" + return { + ...base, + result: held ? shape.held : shape.failed, + state: held ? ("held" as const) : ("failed" as const), + } + } + } + }) +} + +export const checksHeld = (checks: ReadonlyArray): number => + checks.filter((check) => check.state === "held").length + +/* ------------------------------------------------------------------------------------------------- + * Run spine — fan-out segment + * -----------------------------------------------------------------------------------------------*/ + +export interface PlaceholderRunStep { + readonly key: string + readonly label: string + readonly detail: string + readonly tone: "muted" | "active" | "success" | "failed" +} + +/** + * The steps that sit between "Opened" and "Diagnosed" on the run spine. The + * real spine (`investigation-rail.tsx`) still owns Opened, Diagnosed, Escalated, + * Resolved and Failed — those are derived from real timestamps. Only this middle + * segment is invented, and it drops out entirely at a fan-out of one. + */ +export function placeholderRunSteps(investigation: V2Investigation): ReadonlyArray { + if (!hasFanout(investigation)) return [] + const lenses = placeholderLenses(investigation) + const tally = lensTally(lenses) + const size = tally.total + const slowest = lenses.reduce((max, entry) => Math.max(max, entry.elapsedSeconds ?? 0), 0) + + const dispatched: PlaceholderRunStep = { + key: "dispatched", + label: `Dispatched ${size} lenses`, + detail: `Scaled to ${describeSubject(investigation)}`, + tone: "muted", + } + + if (investigation.status === "investigating") { + return [ + dispatched, + { + key: "reporting", + label: `${tally.reported} of ${size} reported`, + detail: "Validation blocked until every lens reports", + tone: "active", + }, + ] + } + + if (investigation.status === "failed") { + return [ + dispatched, + { + key: "inconclusive", + label: "Validation inconclusive", + detail: `${tally.reported} reported · 0 promoted`, + tone: "failed", + }, + ] + } + + return [ + dispatched, + { + key: "all_reported", + label: `All ${size} reported`, + detail: `Slowest lens ${slowest.toFixed(1)}s`, + tone: "muted", + }, + { + key: "validated", + label: "Validated", + detail: `${tally.promoted} promoted · ${tally.merged} merged · ${tally.ruledOut} ruled out`, + tone: "success", + }, + ] +} + +const describeSubject = (investigation: V2Investigation): string => { + if (investigation.subject.type === "freeform") return "a freeform question" + const severity = investigation.severity ?? investigation.snapshot.severity + const kind = `${investigation.subject.incident_kind} incident` + return severity ? `a ${severity} ${kind}` : `an ${kind}` +} + +/* ------------------------------------------------------------------------------------------------- + * Blast radius + * -----------------------------------------------------------------------------------------------*/ + +export interface PlaceholderBlastRadius { + readonly events: number + readonly users: number +} + +/** + * The impact strip's last lane. Event and user counts exist nowhere on the + * investigation — they'd have to come from the incident the subject points at. + */ +export function placeholderBlastRadius(investigation: V2Investigation): PlaceholderBlastRadius { + const next = seedFor(investigation, "blast") + const events = between(next, 120, 4800) + return { events, users: Math.max(1, Math.round(events * (0.15 + next() * 0.3))) } +} diff --git a/apps/web/src/components/investigations/follow-up-composer.tsx b/apps/web/src/components/investigations/follow-up-composer.tsx new file mode 100644 index 000000000..53294cdfe --- /dev/null +++ b/apps/web/src/components/investigations/follow-up-composer.tsx @@ -0,0 +1,31 @@ +import { ArrowUpIcon, ChatBubbleSparkleIcon } from "@/components/icons" + +/** + * The docked follow-up, pinned to the bottom of every document tab. + * + * It looks like a composer but it is a button, and that is deliberate. The real + * composer belongs to `ChatConversation`, which owns the session, the approval + * flow and the failed-send queue; a second text field here would either need a + * duplicate of all that or would swallow what the user typed on the way to the + * Chat tab. Handing off before anything is typed loses nothing. + */ +export function FollowUpComposer({ onSubmit }: { onSubmit: () => void }) { + return ( + + ) +} diff --git a/apps/web/src/components/investigations/hypotheses-tab.tsx b/apps/web/src/components/investigations/hypotheses-tab.tsx new file mode 100644 index 000000000..f9b8deaef --- /dev/null +++ b/apps/web/src/components/investigations/hypotheses-tab.tsx @@ -0,0 +1,131 @@ +import type { V2Investigation } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" + +import { CauseRecap } from "./cause-recap" +import { ConfidenceMeter } from "./confidence-meter" +import { type LensVerdict, fanoutSize, lensTally, placeholderLenses } from "./fanout-placeholder" + +const VERDICT_LABEL: Record = { + promoted: "Promoted", + merged: "Merged", + ruled_out: "Ruled out", + rejected: "Rejected", + pending: "Running", +} + +const VERDICT_TONE: Record = { + promoted: "bg-success/12 text-success", + merged: "bg-muted text-muted-foreground", + ruled_out: "bg-muted text-muted-foreground", + rejected: "bg-destructive/12 text-destructive", + pending: "bg-primary/10 text-primary", +} + +const VERDICT_DOT: Record = { + promoted: "bg-success", + merged: "bg-muted-foreground/60", + ruled_out: "bg-muted-foreground/40", + rejected: "bg-destructive", + pending: "bg-primary animate-pulse", +} + +/** + * The trust payload. A promoted cause on its own asks to be believed; this table + * shows the obvious alternative was dispatched, what it claimed, and the one-line + * reason it lost. A struck-through claim was *made and then rejected* — which is + * a stronger statement than never having been considered. + * + * Never rendered at a fan-out of one: with no rivals there is nothing to rank, + * and `InvestigationTabs` drops the tab entirely. + */ +export function HypothesesTab({ investigation }: { investigation: V2Investigation }) { + const lenses = placeholderLenses(investigation) + const tally = lensTally(lenses) + const size = fanoutSize(investigation) + + return ( +
+ +
+
+
+

+ Hypotheses considered +

+ {summarise(tally)} +
+ + Fan-out scaled to {size} {size === 1 ? "lens" : "lenses"} + +
+
    + {lenses.map((entry) => ( +
  • + + + + {VERDICT_LABEL[entry.verdict]} + + + + {entry.lens.name} + + {entry.elapsedSeconds === null + ? "queued" + : `${entry.elapsedSeconds.toFixed(1)}s · ${entry.toolCount} tools`} + + +
    + {entry.claim ? ( +

    + {entry.claim} +

    + ) : ( +

    + {entry.progressNote ?? "No candidate yet"} +

    + )} + {entry.reason ? ( +

    + Validator: {entry.reason} +

    + ) : null} +
    + + + +
  • + ))} +
+
+
+ ) +} + +const summarise = (tally: ReturnType): string => { + const parts = [`${tally.total} lenses dispatched`] + if (tally.promoted > 0) parts.push(`${tally.promoted} promoted`) + if (tally.merged > 0) parts.push(`${tally.merged} merged`) + if (tally.ruledOut > 0) parts.push(`${tally.ruledOut} ruled out`) + if (tally.rejected > 0) parts.push(`${tally.rejected} rejected`) + if (parts.length === 1) parts.push(`${tally.reported} reported`) + return parts.join(" · ") +} diff --git a/apps/web/src/components/investigations/impact-strip.tsx b/apps/web/src/components/investigations/impact-strip.tsx new file mode 100644 index 000000000..b406668bb --- /dev/null +++ b/apps/web/src/components/investigations/impact-strip.tsx @@ -0,0 +1,147 @@ +import type { ReactNode } from "react" +import type { V2Investigation } from "@maple/domain/http/v2" +import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { getServiceColor } from "@maple/ui/lib/colors" +import { toEpochMs } from "@maple/ui/lib/time-format" + +import { placeholderBlastRadius } from "./fanout-placeholder" + +/** + * Four named lanes under the verdict. Three of them read fields that have been + * on the wire the whole time and rendered nowhere — `report.affectedScope`, the + * union of `evidence[].relatedServices`, and the snapshot's incident window. + * + * Fixed lane widths rather than `gap` alone: the lanes have to hold their + * vertical rules in place as the rail widens and narrows, and a flex-only strip + * re-wraps the moment the right panel opens. + */ +export function ImpactStrip({ investigation }: { investigation: V2Investigation }) { + const { report, snapshot } = investigation + const isSettled = investigation.status !== "investigating" + + const services = servicesTouched(investigation) + const window = incidentWindow(snapshot) + const blast = placeholderBlastRadius(investigation) + + return ( +
+ + + {report?.affectedScope?.trim() || (isSettled ? "Not determined" : "Not yet determined")} + + + + {services.length === 0 ? ( + None recorded + ) : ( + + {services.map((service) => ( + + + {service} + + ))} + + )} + + + {window} + + + {/* Two nowrap groups rather than four flex children: as separate items + the "·" is free to wrap onto a line of its own, which it did. */} + + + + {formatNumber(blast.events)} + {" "} + events + + · + + + {formatNumber(blast.users)} + {" "} + users + + + +
+ ) +} + +/** + * A grid, not a flex row with divider elements between the lanes. + * + * Fixed lane widths plus standalone dividers only line up at one viewport: at the + * real content width (≈830px once the sidebar and the rail take their share) the + * last lane wrapped and left its divider stranded at the end of the row above. + * Grid columns can't strand a separator because the separator *is* the cell's + * left border, and the `nth-child` rules below clear it for whichever cell starts + * a row at that breakpoint. + */ +const STRIP = [ + "grid shrink-0 gap-y-5 px-1", + "grid-cols-2 xl:grid-cols-4", + "[&>*]:border-l [&>*]:pl-6", + // 2-up: every odd cell starts a row. + "[&>*:nth-child(odd)]:border-l-0 [&>*:nth-child(odd)]:pl-0", + // 4-up: only the first cell does, so the odd rule has to be undone. + "xl:[&>*:nth-child(odd)]:border-l xl:[&>*:nth-child(odd)]:pl-6", + "xl:[&>*:first-child]:border-l-0 xl:[&>*:first-child]:pl-0", +].join(" ") + +function Lane({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ) +} + +/** + * Every distinct service any piece of evidence touched, in first-seen order. + * Deduped — the same service usually appears on several findings, and printing + * it three times says nothing. + */ +function servicesTouched(investigation: V2Investigation): ReadonlyArray { + const seen = new Set() + for (const evidence of investigation.report?.evidence ?? []) { + for (const service of evidence.relatedServices) { + const name = service.trim() + if (name) seen.add(name) + } + } + // The scope is a service name often enough to be worth falling back to, and a + // lane reading "None recorded" on a diagnosed incident looks broken. + if (seen.size === 0) { + const scope = investigation.snapshot.scope?.trim() + if (scope && !scope.includes(" ")) seen.add(scope) + } + return [...seen] +} + +/** `14:02 → 14:26 · 24m`, or as much of it as the snapshot actually carries. */ +function incidentWindow(snapshot: V2Investigation["snapshot"]): string { + const startedAt = snapshot.incidentStartedAt + if (!startedAt) return "Not recorded" + const startMs = toEpochMs(startedAt) + if (!Number.isFinite(startMs)) return "Not recorded" + const start = clockTime(startMs) + + const endedAt = snapshot.incidentEndedAt + if (!endedAt) return `${start} → ongoing` + const endMs = toEpochMs(endedAt) + if (!Number.isFinite(endMs) || endMs < startMs) return `${start} → ongoing` + return `${start} → ${clockTime(endMs)} · ${formatDuration(endMs - startMs)}` +} + +const clockTime = (epochMs: number) => + new Date(epochMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) diff --git a/apps/web/src/components/investigations/investigate-bar.tsx b/apps/web/src/components/investigations/investigate-bar.tsx new file mode 100644 index 000000000..7dc75b5ea --- /dev/null +++ b/apps/web/src/components/investigations/investigate-bar.tsx @@ -0,0 +1,58 @@ +import { useState } from "react" +import { Button } from "@maple/ui/components/ui/button" +import { cn } from "@maple/ui/lib/utils" + +import { ChatBubbleSparkleIcon } from "@/components/icons" + +/** + * One field, one verb. Shared by the hub's toolbar and its first-run hero — the + * `accent` variant just turns the ring on, because on the hero this is the only + * thing to do on the page and it should say so. + */ +export function InvestigateBar({ + onSubmit, + busy, + accent = false, + placeholder = "Ask Maple to investigate — a service, a symptom, a question", +}: { + onSubmit: (title: string) => void | Promise + busy: boolean + accent?: boolean + placeholder?: string +}) { + const [subject, setSubject] = useState("") + const trimmed = subject.trim() + + return ( +
{ + event.preventDefault() + if (!trimmed || busy) return + setSubject("") + void onSubmit(trimmed) + }} + > + + setSubject(event.target.value)} + placeholder={placeholder} + aria-label="What should Maple investigate?" + className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" + /> + + + ) +} diff --git a/apps/web/src/components/investigations/investigation-header.tsx b/apps/web/src/components/investigations/investigation-header.tsx new file mode 100644 index 000000000..27c1322f7 --- /dev/null +++ b/apps/web/src/components/investigations/investigation-header.tsx @@ -0,0 +1,133 @@ +import type { V2Investigation } from "@maple/domain/http/v2" +import type { IssueSeverity } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu" + +import { ArrowPathIcon, DotsVerticalIcon } from "@/components/icons" +import { SeverityBadge } from "@/components/errors/severity-badge" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { investigationHeadline, investigationScope } from "./investigation-display" +import { InvestigationStatusBadge, investigationKindLabel } from "./investigation-status" + +/** Only the four canonical severities render as a badge; anything else is unset. */ +const asIssueSeverity = (value: string | null | undefined): IssueSeverity | null => + value === "critical" || value === "high" || value === "medium" || value === "low" ? value : null + +/** + * Eyebrow, headline, state chips — and the lifecycle actions, which moved here + * from the rail. They belong beside the subject they act on, and the rail now + * leads with evidence rather than buttons. + * + * The snapshot facts that used to trail the title are gone: the impact strip on + * the Overview tab says the same things in named lanes, and printing both put the + * incident window on the page twice. + */ +export function InvestigationHeader({ + investigation, + busy, + onResolve, + onRestart, +}: { + investigation: V2Investigation + busy: boolean + onResolve: () => void + onRestart: () => void +}) { + const headline = investigationHeadline(investigation) + const scope = investigationScope(investigation) + const severity = asIssueSeverity(investigation.severity ?? investigation.snapshot.severity) + + return ( + +
+ Investigation + + · + + {investigationKindLabel(investigation.subject)} +
+ {headline} +
+ + {severity ? : null} + {/* `scope` is free text and a system-seeded investigation can carry a + whole paragraph of it. One line, always. */} + {scope ? ( + + {scope} + + ) : null} +
+ + } + > + +
+ ) +} + +function InvestigationActions({ + investigation, + busy, + onResolve, + onRestart, +}: { + investigation: V2Investigation + busy: boolean + onResolve: () => void + onRestart: () => void +}) { + const status = investigation.status + // The design puts a "Stop" here while a pass runs. There is no cancel + // endpoint — the v2 group offers list/retrieve/create/restart/updateStatus and + // nothing else — so a running pass gets Resolve, which is a real capability. + // Wire Stop when the API can actually halt a run. + const primary = + status === "failed" + ? { label: "Retry", onClick: onRestart, variant: "default" as const } + : status === "resolved" + ? { label: "Reopen", onClick: onRestart, variant: "outline" as const } + : { label: "Resolve", onClick: onResolve, variant: "default" as const } + + return ( + <> + + + } + aria-label="More investigation actions" + > + + + + + + Run again + + {status === "resolved" ? null : ( + + Mark resolved + + )} + + + + ) +} diff --git a/apps/web/src/components/investigations/investigation-rail.tsx b/apps/web/src/components/investigations/investigation-rail.tsx index 00813ac74..981e6579a 100644 --- a/apps/web/src/components/investigations/investigation-rail.tsx +++ b/apps/web/src/components/investigations/investigation-rail.tsx @@ -2,144 +2,126 @@ import type { ReactNode } from "react" import { Link } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" import type { IssueEscalationAttemptDocument } from "@maple/domain/http" -import { Button } from "@maple/ui/components/ui/button" import { cn } from "@maple/ui/lib/utils" import { formatDuration, formatNumber } from "@maple/ui/lib/format" import { formatRelativeTime, toEpochMs } from "@maple/ui/lib/time-format" -import { DetailRail } from "@maple/ui/components/detail-rail" -import { SEVERITY_LABEL, SEVERITY_TONE } from "@/components/errors/severity-badge" -import { ConfidenceMeter } from "./confidence-meter" +import { ChecksRail } from "./checks-rail" +import { placeholderRunSteps } from "./fanout-placeholder" import { Result, useAtomValue } from "@/lib/effect-atom" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { investigationOriginLabel } from "./investigation-status" /** - * What the transcript doesn't say. The transcript is the investigation's - * argument; this rail is its record — who opened it, how long the pass took, - * what it cost, what it points at, and whether anyone was told. + * The evidence and the record, in that order. * - * Most of this was on the wire and rendered nowhere: `created_at`, - * `diagnosed_at`, the token counts, `report.severityAssessment`, the incident - * and issue IDs, and — worst — `error`, so a failed pass offered a Retry button - * and never said what went wrong. + * Checks lead, because "3 of 5 held" is the fastest read of whether the verdict + * deserves trust — the full findings are a tab away, this is the summary. Below + * it, the run as the sequence it actually was, then what the investigation points + * at, then what it cost. + * + * The Actions group that used to sit at the top is gone: Resolve / Retry moved to + * the page header, beside the subject they act on. */ -export function InvestigationRail({ - investigation, - busy, - onResolve, - onRestart, -}: { - investigation: V2Investigation - busy: boolean - onResolve: () => void - onRestart: () => void -}) { - const { snapshot, subject, report } = investigation - const isResolved = investigation.status === "resolved" +export function InvestigationRail({ investigation }: { investigation: V2Investigation }) { + const { snapshot, subject } = investigation const issueId = subject.type === "incident" ? subject.issue_id : null return ( -
- - {isResolved || investigation.status === "failed" ? ( - - ) : ( - - )} - + // `RightPanel` supplies no padding of its own — the old rail got its gutters + // from `DetailRail.Group`'s `p-4`, and dropping that component dropped them + // too, so the checks sat flush against the window edge. +
+ - +
+

+ Run +

{issueId ? ( ) : ( )} - - - {report ? ( - - - - - - - {SEVERITY_LABEL[report.severityAssessment]} - - - {investigation.model ? ( - - - {investigation.model} - - - ) : null} - - - ) : null} +
- - - +
+

+ Linked +

+ + {investigationOriginLabel(investigation.seeded_by)} - - {subject.type === "incident" ? ( - - - {subject.incident_id} - - - ) : null} + {issueId ? ( - + {issueId} - + ) : null} - {snapshot.references.length > 0 ? ( -
- {snapshot.references.map((reference) => ( - - ))} -
+ {subject.type === "incident" ? ( + + + {subject.incident_id} + + ) : null} - + {snapshot.references.map((reference) => ( + + + + ))} +
+ +
) } -/** Both counts or neither — a lone number reads as a total and misleads. */ -function TokenRow({ investigation }: { investigation: V2Investigation }) { - const { input_tokens: input, output_tokens: output } = investigation - if (input === null && output === null) return null +function LinkedRow({ label, children }: { label: string; children: ReactNode }) { return ( - - - {input === null ? "—" : formatNumber(input)} in ·{" "} - {output === null ? "—" : formatNumber(output)} out +
+ + {label} - + {children} +
+ ) +} + +/** + * Model and token spend, and who opened this. Deliberately the last thing in the + * rail and deliberately quiet — it qualifies the verdict without competing with + * it, but a diagnosis with no cost attached is an unaudited one. + */ +function Provenance({ investigation }: { investigation: V2Investigation }) { + const { model, input_tokens: input, output_tokens: output } = investigation + const tokens = + input === null && output === null + ? null + : `${input === null ? "—" : formatNumber(input)} in · ${output === null ? "—" : formatNumber(output)} out` + + return ( +
+ {model || tokens ? ( +

{[model, tokens].filter(Boolean).join(" · ")}

+ ) : null} +

+ {investigation.seeded_by === "system" + ? "Opened automatically by Maple" + : "Opened by a member of your team"} +

+
) } @@ -158,14 +140,22 @@ interface SpineNode { detail?: ReactNode } +const STEP_DOT: Record = { + muted: "bg-muted-foreground/50", + active: "bg-primary animate-pulse", + success: "bg-success", + failed: "bg-destructive", +} + /** * The diagnostic pass as the sequence it actually is. The elapsed time sits on * the connector between Opened and Diagnosed rather than in a stat row, because - * that is what it is — the gap between two events, not a standalone metric. It - * is also the number the product is about, and until now nothing rendered it. + * that is what it is — the gap between two events. * - * Escalation is the spine's terminal event rather than a separate card: it is - * the last thing that happened in the same run. + * The middle of the spine (dispatch → report → validate) comes from + * `placeholderRunSteps` and is the only invented part; the endpoints are real + * timestamps. Escalation is the spine's terminal event rather than a separate + * card: it is the last thing that happened in the same run. */ function RunSpine({ investigation, @@ -188,23 +178,36 @@ function RunSpine({ label: "Opened", at: investigation.created_at, dot: "bg-muted-foreground/50", - ...(elapsed ? { gap: elapsed } : {}), + detail: ( +

+ {investigationOriginLabel(investigation.seeded_by)} ·{" "} + {investigation.subject.type === "freeform" + ? "question" + : `${investigation.subject.incident_kind} incident`} +

+ ), }) - if (investigation.status === "investigating") { + for (const step of placeholderRunSteps(investigation)) { nodes.push({ - key: "investigating", - label: "Investigating…", - dot: "bg-primary animate-pulse", + key: step.key, + label: step.label, + dot: STEP_DOT[step.tone] ?? "bg-muted-foreground/50", + detail:

{step.detail}

, }) } + if (investigation.status === "investigating" && nodes.length === 1) { + nodes.push({ key: "investigating", label: "Investigating…", dot: "bg-primary animate-pulse" }) + } + if (investigation.diagnosed_at) { nodes.push({ key: "diagnosed", label: "Diagnosed", at: investigation.diagnosed_at, dot: "bg-success", + ...(elapsed ? { gap: elapsed } : {}), }) } @@ -225,9 +228,9 @@ function RunSpine({ at: investigation.updated_at, dot: "bg-destructive", detail: investigation.error ? ( -

{investigation.error}

+

{investigation.error}

) : ( -

+

No failure reason was recorded. Retry to run the pass again.

), @@ -248,7 +251,7 @@ function RunSpine({ {nodes.map((node, index) => { const isLast = index === nodes.length - 1 return ( -
  • +
  • {isLast ? null : ( {label} @@ -379,7 +382,7 @@ function ReferenceLink({ label, url }: { label: string; url: string }) { ) } return ( - + {label} ) diff --git a/apps/web/src/components/investigations/investigation-table.tsx b/apps/web/src/components/investigations/investigation-table.tsx index bac93a623..bf0afde70 100644 --- a/apps/web/src/components/investigations/investigation-table.tsx +++ b/apps/web/src/components/investigations/investigation-table.tsx @@ -1,92 +1,39 @@ -import type { MouseEvent, ReactNode } from "react" -import { TableSkeleton } from "@maple/ui/components/ui/table-skeleton" +import type { MouseEvent } from "react" import { Link, useNavigate } from "@tanstack/react-router" import type { V2Investigation } from "@maple/domain/http/v2" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" import { formatRelativeTime, toEpochMs } from "@maple/ui/lib/time-format" -import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "@/components/icons" import { SeverityBadge } from "@/components/errors/severity-badge" import { ConfidenceMeter } from "./confidence-meter" +import { hasFanout, lensTally, placeholderLenses } from "./fanout-placeholder" import { investigationFinding, investigationHeadline, investigationScope, investigationSeverity, - type InvestigationSortKey, - type SortDirection, } from "./investigation-display" import { InvestigationKindMarker, InvestigationStatusBadge } from "./investigation-status" -export interface InvestigationSort { - readonly key: InvestigationSortKey - readonly direction: SortDirection -} - /** - * The fixed columns come to 420px, and Subject and Finding need room to say - * anything at all. `min-width` on a `th` is ignored under `table-layout: fixed` - * — the floor has to live on the table, so a narrow viewport scrolls the - * wrapper instead of crushing the two prose columns to nothing. + * Not a table any more. + * + * The old grid gave Subject and Finding a column each and then had to truncate + * both to fit five more, so the one thing worth reading — what Maple concluded — + * arrived as half a sentence. Stacking the finding under the subject gives it the + * full width of the row, and the remaining columns shrink to what they actually + * need: a meter, a badge, a word, a timestamp. + * + * Sorting moved to the toolbar's select, which is why no header row survives. */ -const TABLE_LAYOUT = "table-fixed min-w-[56rem]" - -/** - * Left to right: what happened → how bad → what Maple concluded → how sure it - * is → where the run stands → when. Severity and confidence used to sit as two - * interchangeable word columns; confidence now sits against the finding it - * qualifies, and kind and origin fold into the subject's marker rather than - * spending a column each on one repeated word. - */ -export function InvestigationTable({ - investigations, - sort, - onSort, -}: { - investigations: ReadonlyArray - sort: InvestigationSort - onSort: (key: InvestigationSortKey) => void -}) { +export function InvestigationTable({ investigations }: { investigations: ReadonlyArray }) { return ( -
    - - - - Subject - - Finding - - Status - - - - - {investigations.map((investigation) => ( - - ))} - -
    -
    +
      + {investigations.map((investigation) => ( + + ))} +
    ) } @@ -96,152 +43,119 @@ function InvestigationRow({ investigation }: { investigation: V2Investigation }) const scope = investigationScope(investigation) const finding = investigationFinding(investigation) - // The headline stays a real link so cmd-click and "open in new tab" work; - // the row handler is the convenience path and must not double-fire on it. - const openRow = (event: MouseEvent) => { + // The headline stays a real link so cmd-click and "open in new tab" work; the + // row handler is the convenience path and must not double-fire on it. + const openRow = (event: MouseEvent) => { if (event.defaultPrevented) return if ((event.target as HTMLElement).closest("a")) return void navigate({ to: "/investigations/$id", params: { id: investigation.id } }) } return ( - - -
    - -
    - - {headline} - - {scope ? ( -

    {scope}

    - ) : null} -
    -
    -
    - - - - - {finding.kind === "none" ? ( - - ) : ( - - {finding.kind === "pending" ? ( - - ) : null} - - {finding.text} +
  • + + + +
    +
    + + {headline} + + {scope ? ( + + {scope} - - )} - - + ) : null} +
    + +
    + - - + + + + + - - - - - + + +
  • ) } -function SortableHead({ - label, - sortKey, - sort, - onSort, - align = "left", - className, +/** + * The row's second line. A running pass says how far through the fan-out it is + * rather than a generic "gathering evidence…" — that is the one thing someone + * watching a live investigation actually wants from a list. + */ +function RowFinding({ + investigation, + finding, }: { - label: string - sortKey: InvestigationSortKey - sort: InvestigationSort - onSort: (key: InvestigationSortKey) => void - align?: "left" | "right" - className?: string + investigation: V2Investigation + finding: ReturnType }) { - const isActive = sort.key === sortKey - return ( - - - - ) -} - -function SortGlyph({ active, direction }: { active: boolean; direction: SortDirection }): ReactNode { - if (!active) { + if (finding.kind === "pending" && hasFanout(investigation)) { + const tally = lensTally(placeholderLenses(investigation)) return ( - + + + + {tally.reported} of {tally.total} lenses reported · validator blocked + + ) } - return direction === "desc" ? ( - - ) : ( - + if (finding.kind === "none") { + return + } + return ( + + {finding.kind === "pending" ? ( + + ) : null} + {finding.text} + ) } -/** Ghosts the real column widths, so the table doesn't reflow when rows land. */ +/** Ghosts the real row rhythm, so the list doesn't reflow when rows land. */ export function InvestigationTableSkeleton() { return ( -
    - -
    +
      + {Array.from({ length: 5 }, (_, index) => ( +
    • + +
      + + +
      + + + + +
    • + ))} +
    ) } diff --git a/apps/web/src/components/investigations/investigation-tabs.tsx b/apps/web/src/components/investigations/investigation-tabs.tsx new file mode 100644 index 000000000..e03894781 --- /dev/null +++ b/apps/web/src/components/investigations/investigation-tabs.tsx @@ -0,0 +1,79 @@ +import { Link } from "@tanstack/react-router" +import type { V2Investigation } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" + +import { hasFanout, placeholderLenses } from "./fanout-placeholder" + +export const INVESTIGATION_TABS = ["overview", "evidence", "hypotheses", "chat", "transcript"] as const + +export type InvestigationTab = (typeof INVESTIGATION_TABS)[number] + +/** + * The tabs are ``s, not a `Tabs` primitive. Three reasons: the strip is + * pinned in `Sticky` while the panel scrolls in `Scroll`, so a single `Tabs` root + * would have to straddle two layout regions; the active tab belongs in the URL so + * a link to the Evidence tab survives a reload and a share; and links give + * middle-click and browser history for free. + */ +export function InvestigationTabs({ + investigation, + active, +}: { + investigation: V2Investigation + active: InvestigationTab +}) { + const evidenceCount = investigation.report?.evidence.length ?? 0 + // At a fan-out of one there are no rivals to rank, so the section would be an + // empty table with a heading — the design drops the tab entirely. + const showHypotheses = hasFanout(investigation) + const hypothesesCount = showHypotheses ? placeholderLenses(investigation).length : 0 + + const tabs: ReadonlyArray<{ value: InvestigationTab; label: string; count?: number }> = [ + { value: "overview", label: "Overview" }, + ...(evidenceCount > 0 + ? [{ value: "evidence" as const, label: "Evidence", count: evidenceCount }] + : []), + ...(showHypotheses + ? [{ value: "hypotheses" as const, label: "Hypotheses", count: hypothesesCount }] + : []), + { value: "chat", label: "Chat" }, + { value: "transcript", label: "Transcript" }, + ] + + return ( + // Full-bleed out of the sticky area's `p-4` and flush to its bottom edge, so + // the underline reads as the boundary between header and content instead of + // a rule floating 16px above one. +
    + {tabs.map((tab) => { + const isActive = tab.value === active + return ( + + {tab.label} + {tab.count === undefined ? null : ( + {tab.count} + )} + + ) + })} +
    + ) +} diff --git a/apps/web/src/components/investigations/investigation-view.tsx b/apps/web/src/components/investigations/investigation-view.tsx index 33c63e913..3b5aeed52 100644 --- a/apps/web/src/components/investigations/investigation-view.tsx +++ b/apps/web/src/components/investigations/investigation-view.tsx @@ -1,18 +1,24 @@ import { useMemo, useState } from "react" +import { useNavigate } from "@tanstack/react-router" import { Exit } from "effect" import { useAtomSet } from "@/lib/effect-atom" import type { V2Investigation } from "@maple/domain/http/v2" -import type { IssueSeverity } from "@maple/domain/http" import { toastManager } from "@maple/ui/components/ui/toast" import { ChatConversation } from "@/components/chat/chat-conversation" import type { InvestigationContext } from "@/components/chat/investigation-context" -import { SeverityBadge } from "@/components/errors/severity-badge" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { investigationHeadline, investigationScope } from "./investigation-display" +import { EvidenceTab } from "./evidence-tab" +import { FollowUpComposer } from "./follow-up-composer" +import { HypothesesTab } from "./hypotheses-tab" +import { ImpactStrip } from "./impact-strip" +import { investigationHeadline } from "./investigation-display" +import { InvestigationHeader } from "./investigation-header" import { InvestigationRail } from "./investigation-rail" -import { InvestigationStatusBadge, investigationKindLabel } from "./investigation-status" +import { type InvestigationTab, InvestigationTabs } from "./investigation-tabs" +import { NextActions } from "./next-actions" +import { VerdictCard } from "./verdict-card" const factKey = (label: string) => label @@ -31,10 +37,6 @@ const breadcrumbLabel = (title: string): string => { return line.length > 64 ? `${line.slice(0, 63).trimEnd()}…` : line } -/** Only the four canonical severities render as a badge; anything else is unset. */ -const asIssueSeverity = (value: string | null | undefined): IssueSeverity | null => - value === "critical" || value === "high" || value === "medium" || value === "low" ? value : null - /** * Read a snapshot fact by label. The snapshot is the only place the signal type * survives — it isn't a column on the investigation — and both writers emit it @@ -84,19 +86,27 @@ const contextFromInvestigation = (investigation: V2Investigation): Investigation } /** - * One investigation, as a workspace rather than a document: the header states the - * subject, the transcript owns the rest of the viewport and its own scrolling, and - * the rail carries everything the transcript doesn't — the run's history, what it - * cost, what it points at, and the actions that change its state. Nothing appears - * twice. + * One investigation, as a finding rather than a conversation. + * + * The transcript used to *be* this page — the diagnosis was a card buried in a + * scrolling chat, and everything that qualified it lived in the rail. Now the + * verdict, its impact and what to do about it are the page; the conversation is + * one tab among five, and the rail leads with the checks that back the verdict. + * + * The follow-up composer is docked on every document tab, because the question a + * person wants to ask arrives while they're reading the evidence, not after + * they've navigated away from it. */ export function InvestigationView({ investigation, + tab, onRefresh, }: { investigation: V2Investigation + tab: InvestigationTab onRefresh: () => void }) { + const navigate = useNavigate() const [busy, setBusy] = useState(false) const restart = useAtomSet(MapleApiV2AtomClient.mutation("investigations", "restart"), { mode: "promiseExit", @@ -140,6 +150,26 @@ export function InvestigationView({ } } + /** + * The docked composer doesn't own a chat session. Lifting `useMapleChat` out + * of `ChatConversation` to share one would mean rebuilding approvals, failed + * sends and history loading around it — so the question is handed to the Chat + * tab, which is where the answer belongs anyway. + */ + const handleFollowUp = () => { + void navigate({ + to: "/investigations/$id", + params: { id: investigation.id }, + search: { tab: "chat" }, + }) + } + + // Chat and Transcript own their own scrolling and fill the viewport; the + // document tabs scroll as a page. `Fill` vs `Scroll` is exactly that + // difference — nesting a self-scrolling transcript inside a scrolling page is + // what used to need a `calc(100dvh - 12rem)` guess. + const isConversation = tab === "chat" || tab === "transcript" + return ( - {/* No actions here: Resolve/Reopen/Retry live in the rail, beneath the - run history they act on. The header states the subject and nothing else. */} - } + + - {/* `Fill`, not `Scroll`: the transcript scrolls itself. Nesting it in a - scrolling page is what previously needed a `calc(100dvh - 12rem)` - guess that the billing banners could invalidate. */} - - - + {isConversation ? ( + + + + ) : ( + +
    + {tab === "evidence" ? ( + + ) : tab === "hypotheses" ? ( + + ) : ( + <> + + + + + )} + {isResolved ? null : } +
    +
    + )}
    - +
    ) } - -/** - * Eyebrow, title, and the snapshot facts as one line of prose-with-values — - * following the anomaly hero rather than a grid of chips, so the subject reads as - * a sentence and the numbers still stand out. - */ -function InvestigationHeading({ investigation }: { investigation: V2Investigation }) { - const { snapshot } = investigation - const severity = asIssueSeverity(investigation.severity ?? snapshot.severity) - // Same derivation as the list, or the two surfaces name the same - // investigation differently. - const headline = investigationHeadline(investigation) - const scope = investigationScope(investigation) - - return ( -
    -
    - Investigation - · - {investigationKindLabel(investigation.subject)} -
    - {headline} -
    - - {severity ? : null} - {/* `scope` is free text and a system-seeded investigation can carry a - whole paragraph of it. One line, always — the full string is on the - title, and the diagnosis card states the scope properly anyway. */} - {scope ? ( - - {scope} - - ) : null} -
    - {snapshot.facts.length > 0 ? ( -

    - {snapshot.facts.map((fact) => ( - - {fact.label}{" "} - {/* `inline-block`, or `truncate`'s overflow rules do nothing here. */} - - {fact.value} - - - ))} -

    - ) : null} -
    - ) -} diff --git a/apps/web/src/components/investigations/next-actions.tsx b/apps/web/src/components/investigations/next-actions.tsx new file mode 100644 index 000000000..59d3af541 --- /dev/null +++ b/apps/web/src/components/investigations/next-actions.tsx @@ -0,0 +1,122 @@ +import { Link } from "@tanstack/react-router" +import type { V2Investigation } from "@maple/domain/http/v2" +import { Button } from "@maple/ui/components/ui/button" + +/** + * `report.suggestedActions` as a numbered ledger rather than a bulleted list — + * they arrive ordered by expected impact and a list hides that, so the ordinal + * is the point. + * + * Each row offers the one place the action is carried out. The API returns + * prose, not structured actions, so the destination is inferred from the verb; + * a row we can't route anywhere simply has no button rather than a dead one. + */ +export function NextActions({ investigation }: { investigation: V2Investigation }) { + const actions = investigation.report?.suggestedActions ?? [] + if (actions.length === 0) return null + + return ( +
    +
    +

    + Next actions +

    + ordered by expected impact +
    +
      + {actions.map((action, index) => ( +
    1. + + {String(index + 1).padStart(2, "0")} + +

      {action}

      + +
    2. + ))} +
    +
    + ) +} + +/** + * Best-effort routing from a sentence to a destination. Deliberately + * conservative — the wrong link is worse than none, so anything that doesn't + * match a clear verb gets an empty slot that still holds the column lane. + */ +function ActionTarget({ action, investigation }: { action: string; investigation: V2Investigation }) { + const text = action.toLowerCase() + const slot = "flex w-33 shrink-0 justify-end" + + if (text.includes("alert")) { + return ( + + + + ) + } + if (text.includes("dashboard")) { + return ( + + + + ) + } + if (text.includes("trace") || text.includes("span")) { + return ( + + + + ) + } + if (text.includes("log")) { + return ( + + + + ) + } + const issueId = investigation.subject.type === "incident" ? investigation.subject.issue_id : null + if (issueId && (text.includes("issue") || text.includes("error"))) { + return ( + + + + ) + } + return +} diff --git a/apps/web/src/components/investigations/verdict-card.tsx b/apps/web/src/components/investigations/verdict-card.tsx new file mode 100644 index 000000000..8d86fb015 --- /dev/null +++ b/apps/web/src/components/investigations/verdict-card.tsx @@ -0,0 +1,508 @@ +import type { ReactNode } from "react" +import type { V2Investigation } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" +import { formatDuration } from "@maple/ui/lib/format" +import { toEpochMs } from "@maple/ui/lib/time-format" + +import { SEVERITY_LABEL } from "@/components/errors/severity-badge" +import { CheckIcon } from "@/components/icons" +import { ConfidenceMeter } from "./confidence-meter" +import { + type PlaceholderLens, + hasFanout, + lensTally, + placeholderLenses, + placeholderValidator, +} from "./fanout-placeholder" + +/** + * What the investigation concluded, or how far it has got trying. One card, three + * shapes — diagnosed, running, failed — because they answer the same question at + * different stages and swapping between them shouldn't move the page around. + * + * The left edge is a 3px accent rule, so the card is square on that side: a + * rounded corner behind a flat bar leaves a sliver of card showing above and + * below the rule, which reads as a rendering bug. + */ +export function VerdictCard({ investigation }: { investigation: V2Investigation }) { + if (investigation.status === "investigating") { + return + } + if (investigation.status === "failed") { + return + } + return +} + +/* ------------------------------------------------------------------------------------------------- + * Shell + * -----------------------------------------------------------------------------------------------*/ + +function VerdictShell({ + accent, + children, + stats, +}: { + accent: string + children: ReactNode + stats: ReactNode +}) { + return ( + // `shrink-0`: the page column is `min-h-full`, so without it a tall card is + // the flex item that absorbs the shortfall and collapses to its borders + // while its content overflows into the section below. + // Square on whichever edge carries the accent rule — a rounded corner behind + // a flat bar leaves a sliver of card showing past it, which reads as a + // rendering bug. That edge is the left when the card is a row, the top once + // it stacks. +
    + {/* The accent rule runs the full height as a column edge, but once the + card stacks it has to become a top edge or it caps the card at 3px. */} + +
    {children}
    + {/* Stacked below `lg` rather than hidden: confidence and AI severity are + the two things that qualify the verdict, and dropping them on a narrow + window leaves an unqualified claim. */} +
    + {stats} +
    +
    + ) +} + +function Stat({ label, children, last }: { label: string; children: ReactNode; last?: boolean }) { + return ( +
    + + {label} + +
    {children}
    +
    + ) +} + +/** A big number with a small unit riding its baseline. */ +function BigStat({ value, unit }: { value: string; unit: string }) { + return ( + + + {value} + + {unit} + + ) +} + +function Eyebrow({ children, tone }: { children: ReactNode; tone: string }) { + return ( +
    + {children} +
    + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Diagnosed + * -----------------------------------------------------------------------------------------------*/ + +function DiagnosedVerdict({ investigation }: { investigation: V2Investigation }) { + const report = investigation.report + const promoted = hasFanout(investigation) + ? placeholderLenses(investigation).find((lens) => lens.verdict === "promoted") + : undefined + + if (!report) { + return ( + + + + } + > + No diagnosis recorded +

    + The pass finished without attaching a report. Run it again to try for a cause. +

    +
    + ) + } + + const timeToDiagnosis = elapsedBetween(investigation.created_at, investigation.diagnosed_at) + + return ( + + + + + + + {SEVERITY_LABEL[report.severityAssessment]} + + + + {timeToDiagnosis ? ( + + ) : ( + + )} + + + } + > + + Suspected cause + {promoted ? ( + <> + + · + + + + Validated + + + promoted from the {promoted.lens.name.toLowerCase()} lens + + + ) : null} + +

    + {report.suspectedCause} +

    +

    {report.summary}

    +
    + ) +} + +/** The badge tones are backgrounds; the stat column wants the text colour alone. */ +const SEVERITY_TEXT_TONE: Record = { + critical: "text-destructive", + high: "text-destructive", + medium: "text-severity-warn", + low: "text-muted-foreground", + unclassified: "text-muted-foreground", +} + +/* ------------------------------------------------------------------------------------------------- + * Investigating + * -----------------------------------------------------------------------------------------------*/ + +const COUNT_WORD = ["No", "One", "Two", "Three", "Four", "Five"] as const +const countWord = (n: number) => COUNT_WORD[n] ?? String(n) + +function InvestigatingVerdict({ investigation }: { investigation: V2Investigation }) { + const lenses = placeholderLenses(investigation) + const tally = lensTally(lenses) + const validator = placeholderValidator(investigation) + const fanned = hasFanout(investigation) + const elapsed = elapsedSince(investigation.created_at) + + return ( + + + {elapsed ? ( + + ) : ( + + )} + + {fanned ? ( + + + {tally.reported} of {tally.total} + + + ) : null} + {validator ? ( + + Blocked + + ) : null} + + Pending + + + } + > + + + + Investigating + + {fanned ? ( + <> + + · + + {tally.total} lenses in flight + + ) : null} + +

    + {fanned + ? `${countWord(tally.total)} agents are attacking this from different angles. ${countWord(tally.reported)} ${tally.reported === 1 ? "has" : "have"} reported.` + : "Maple is gathering evidence."} +

    + {fanned ? ( + + ) : ( +

    + One agent is working this question. The transcript shows what it is doing as it goes. +

    + )} +
    + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Failed + * -----------------------------------------------------------------------------------------------*/ + +function FailedVerdict({ investigation }: { investigation: V2Investigation }) { + const lenses = placeholderLenses(investigation) + const tally = lensTally(lenses) + const fanned = hasFanout(investigation) + const validator = placeholderValidator(investigation) + const ranFor = elapsedBetween(investigation.created_at, investigation.updated_at) + + return ( + + + {ranFor ? ( + + ) : ( + + )} + + {fanned ? ( + + + {tally.reported} of {tally.total} + + + ) : null} + + {fanned ? "Rejected all" : "None"} + + + None + + + } + > + + No diagnosis + {fanned ? ( + <> + + · + + Validator rejected every candidate + + ) : null} + +

    + {fanned + ? `${countWord(tally.reported)} lenses reported, and none of them held up` + : "The pass ended without a diagnosis"} +

    +

    + {fanned + ? "The candidates contradicted each other, so Maple promoted nothing rather than guess. What each lens did gather is kept below — a retry re-runs the fan-out with a wider evidence budget." + : "Nothing was promoted. Retry to run the pass again."} +

    + {/* The raw error was on the wire and rendered nowhere but a toast. */} + {investigation.error ? ( +
    + + reason + + + {investigation.error} + +
    + ) : null} + {fanned ? : null} +
    + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Lens lanes + * -----------------------------------------------------------------------------------------------*/ + +const LANE_DOT: Record = { + reported: "bg-success", + checking: "bg-primary animate-pulse", + queued: "border border-muted-foreground/40", + no_finding: "bg-muted-foreground/40", +} + +const LANE_NOTE: Record = { + reported: "reported a candidate", + checking: "checking", + queued: "queued", + no_finding: "no finding", +} + +/** + * One row per dispatched lens: where it got to, what it claimed, how long it + * took. On a failed run the claims are struck through — they were reported and + * then rejected, which is different from never having been made. + */ +function LensLanes({ + lenses, + validator, +}: { + lenses: ReadonlyArray + validator: ReturnType +}) { + // `flex-wrap` + a `min-w-*` floor on the claim: with the rail open and the + // stat column showing, a fixed three-column lane crushes the claim to a + // five-word-per-line ribbon. Below the floor the claim drops to its own line + // at full width instead. + return ( +
      + {lenses.map((entry) => ( +
    • + + + {entry.lens.name} + + {entry.progressNote ?? LANE_NOTE[entry.status]} + + + + {entry.claim ?? entry.reason ?? "—"} + + + {entry.elapsedSeconds === null ? "—" : `${entry.elapsedSeconds.toFixed(1)}s`} + +
    • + ))} + {validator ? ( +
    • + + + + Validator + + + {validator.status === "blocked" + ? "blocked" + : validator.status === "rejected_all" + ? "rejected all" + : "ranked"} + + + {validator.note} + + {validator.elapsedSeconds === null ? "—" : `${validator.elapsedSeconds.toFixed(1)}s`} + +
    • + ) : null} +
    + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Elapsed helpers + * -----------------------------------------------------------------------------------------------*/ + +interface Elapsed { + value: string + unit: string +} + +/** + * `formatDuration` returns one string ("38s", "2m 4s"); the stat wants the number + * and the unit apart so the number can carry the display weight. + */ +const splitDuration = (ms: number): Elapsed => { + // A pass that died before it started is a real case (`workflow_binding_unavailable` + // fails in microseconds), and "0 µs" reads as a broken clock rather than an + // instant failure. Sub-second resolution buys nothing at this display size. + if (ms < 1000) return { value: "<1", unit: "s" } + // `formatDuration` is tuned for span durations and gives seconds two decimals + // ("7.04s"). At this display weight that reads as false precision, and on a live + // pass the hundredths churn on every 3s poll — whole seconds up to a minute. + if (ms < 60_000) return { value: String(Math.round(ms / 1000)), unit: "s" } + const formatted = formatDuration(ms) + const match = /^([\d.]+)\s*(.*)$/.exec(formatted) + return match ? { value: match[1]!, unit: match[2]! } : { value: formatted, unit: "" } +} + +function elapsedBetween(from: string, to: string | null): Elapsed | null { + if (!to) return null + const start = toEpochMs(from) + const end = toEpochMs(to) + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null + return splitDuration(end - start) +} + +/** + * Recomputed on every render rather than ticked on a timer — the detail page + * already polls every 3s while a pass runs, so the number advances without a + * second interval fighting the first. + */ +function elapsedSince(from: string): Elapsed | null { + const start = toEpochMs(from) + if (!Number.isFinite(start)) return null + return splitDuration(Math.max(0, Date.now() - start)) +} diff --git a/apps/web/src/routes/investigations/$id.tsx b/apps/web/src/routes/investigations/$id.tsx index 437595b31..3b64b2545 100644 --- a/apps/web/src/routes/investigations/$id.tsx +++ b/apps/web/src/routes/investigations/$id.tsx @@ -1,8 +1,10 @@ +import type { ReactNode } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { Exit, Option, Schema } from "effect" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { decodeInvestigationRef } from "@/components/chat/investigation-context" +import { INVESTIGATION_TABS } from "@/components/investigations/investigation-tabs" import { InvestigationView } from "@/components/investigations/investigation-view" import { ErrorState } from "@/components/common/error-state" import { DashboardLayout } from "@/components/layout/dashboard-layout" @@ -18,6 +20,11 @@ import { useState } from "react" const SearchSchema = Schema.Struct({ /** One-release redirect shim for legacy encoded resource URLs. */ r: Schema.optional(Schema.String), + /** + * The open tab. Absent means Overview, so the canonical URL for an + * investigation stays clean and a link to Evidence survives a reload. + */ + tab: Schema.optional(Schema.Literals(INVESTIGATION_TABS)), }) export const Route = createFileRoute("/investigations/$id")({ @@ -29,7 +36,7 @@ const decodeInvestigationId = Schema.decodeUnknownOption(InvestigationId) function InvestigationPage() { const { id: rawId } = Route.useParams() - const { r } = Route.useSearch() + const { r, tab } = Route.useSearch() // `InvestigationId` is a branded UUID. Decoding it with the throwing variant // took down the whole route on any other shape — including the legacy encoded // ids the `?r=` migration below exists to rescue, which made that path @@ -39,17 +46,19 @@ function InvestigationPage() { const legacyRef = r ? decodeInvestigationRef(r) : undefined return legacyRef ? : } - return + return } function InvestigationDetail({ id, legacyRef, rawId, + tab, }: { id: InvestigationId legacyRef: string | undefined rawId: string + tab: (typeof INVESTIGATION_TABS)[number] | undefined }) { const query = MapleApiV2AtomClient.query("investigations", "retrieve", { params: { id }, @@ -75,7 +84,9 @@ function InvestigationDetail({ ) }) - .onSuccess((investigation) => ) + .onSuccess((investigation) => ( + + )) .render() } @@ -87,30 +98,46 @@ const isNotFound = (error: unknown): boolean => typeof error._tag === "string" && error._tag.toLowerCase().includes("notfound") -function LoadFailureShell({ error, onRetry }: { error: unknown; onRetry: () => void }) { +/** + * Every non-success state wears the same chrome — breadcrumbs, a sticky header, + * a scrolling body — and four hand-copied versions of it drifted apart the moment + * anything about the shell changed. Only the trail label, the title and the body + * differ, so those are the parameters. + */ +function InvestigationShell({ + trail, + title, + children, +}: { + trail: string + title: string + children: ReactNode +}) { return ( - + - - - + {children} ) } +function LoadFailureShell({ error, onRetry }: { error: unknown; onRetry: () => void }) { + return ( + + + + ) +} + function LegacyInvestigationRedirect({ legacyId }: { legacyId: string }) { const navigate = useNavigate() const create = useAtomSet(MapleApiV2AtomClient.mutation("investigations", "create"), { @@ -154,102 +181,48 @@ function LegacyInvestigationRedirect({ legacyId }: { legacyId: string }) { useMountEffect(migrate) if (failed) { return ( - + + + + Investigation migration failed + The legacy investigation could not be migrated. + + + + ) } return } -function MutationFailureShell({ - title, - description, - onRetry, -}: { - title: string - description: string - onRetry: () => void -}) { - return ( - - - - - - - - - - - {title} - {description} - - - - - - - - ) -} - function LoadingShell({ label = "Loading investigation…" }: { label?: string }) { return ( - - - - - - - - -
    - - - -
    -
    -
    -
    -
    + +
    + + + +
    +
    ) } function NotFoundShell() { return ( - - - - - - - - - - - This investigation is unavailable - - It may have been removed, or it belongs to a different organization. - - - - - - - - + + + + This investigation is unavailable + + It may have been removed, or it belongs to a different organization. + + + + + ) } diff --git a/apps/web/src/routes/investigations/index.tsx b/apps/web/src/routes/investigations/index.tsx index df95ee9b0..f51330f55 100644 --- a/apps/web/src/routes/investigations/index.tsx +++ b/apps/web/src/routes/investigations/index.tsx @@ -5,14 +5,15 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import type { V2Investigation } from "@maple/domain/http/v2" import { Button } from "@maple/ui/components/ui/button" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@maple/ui/components/ui/empty" -import { InputGroup, InputGroupAddon, InputGroupInput } from "@maple/ui/components/ui/input-group" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { ToolbarSearch } from "@maple/ui/components/toolbar" import { toastManager } from "@maple/ui/components/ui/toast" +import { formatDuration } from "@maple/ui/lib/format" +import { toEpochMs } from "@maple/ui/lib/time-format" import { ErrorState } from "@/components/common/error-state" import { ListToolbar } from "@/components/common/list-toolbar" -import { ChatBubbleSparkleIcon } from "@/components/icons" +import { fanoutSize } from "@/components/investigations/fanout-placeholder" import { investigationKindKey, matchesQuery, @@ -24,6 +25,7 @@ import { InvestigationTable, InvestigationTableSkeleton, } from "@/components/investigations/investigation-table" +import { InvestigateBar } from "@/components/investigations/investigate-bar" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" @@ -67,6 +69,21 @@ const kindFilterLabel = (value: unknown): string => ? KIND_FILTER_LABEL[value as InvestigationKindKey | "all"] : KIND_FILTER_LABEL.all +/** + * Sorting moved off the column headers, because the list no longer has any. Each + * option is a `key:direction` pair so the control reads as an intent ("Most + * severe") rather than a field plus a separate direction toggle. + */ +const SORT_OPTIONS = [ + { value: "updated:desc", label: "Newest first" }, + { value: "updated:asc", label: "Oldest first" }, + { value: "severity:desc", label: "Most severe" }, + { value: "confidence:desc", label: "Most confident" }, +] as const + +const sortLabel = (value: unknown): string => + SORT_OPTIONS.find((option) => option.value === value)?.label ?? SORT_OPTIONS[0].label + function InvestigationsHub() { const navigate = useNavigate({ from: Route.fullPath }) const search = Route.useSearch() @@ -76,7 +93,6 @@ function InvestigationsHub() { const query = search.q ?? "" const isFiltered = query.trim().length > 0 || search.kind !== undefined - const [subject, setSubject] = useState("") const [creating, setCreating] = useState(false) const listQuery = MapleApiV2AtomClient.query("investigations", "list", { query: { limit: PAGE_SIZE }, @@ -110,21 +126,7 @@ function InvestigationsHub() { return sortInvestigations(filtered, sortKey, sortDirection) }, [page, view, search.kind, query, sortKey, sortDirection]) - const handleSort = (key: InvestigationSortKey) => { - void navigate({ - search: (prev) => ({ - ...prev, - sort: key === "updated" ? undefined : key, - // A first click on a column means "most of this first"; clicking the - // active column flips it. - dir: key === sortKey && sortDirection === "desc" ? "asc" : undefined, - }), - }) - } - - const handleCreate = async () => { - const title = subject.trim() - if (!title) return + const handleCreate = async (title: string) => { setCreating(true) const created = await create({ payload: { @@ -144,13 +146,17 @@ function InvestigationsHub() { }) setCreating(false) if (Exit.isSuccess(created)) { - setSubject("") void navigate({ to: "/investigations/$id", params: { id: created.value.id } }) } else { toastManager.add({ title: "Investigation could not be started", type: "error" }) } } + // Nothing at all — not "nothing matching your filters". The hero belongs to a + // workspace that has never run one, and it replaces the whole page rather than + // sitting inside an empty table shell. + const isFirstRun = Result.isSuccess(result) && page.length === 0 + const toolbar = ( + void navigate({ search: (prev) => ({ ...prev, q: value }) })} @@ -213,78 +244,242 @@ function InvestigationsHub() { - - -
    { - event.preventDefault() - void handleCreate() - }} - > - - - - - setSubject(event.target.value)} - placeholder="Ask Maple to investigate — a service, a symptom, a question" - aria-label="What should Maple investigate?" - /> - - -
    -
    - -
    - {toolbar} - {Result.builder(result) - .onInitial(() => ) - .onError((error) => ( - - )) - .onSuccess(() => - investigations.length === 0 ? ( - - void navigate({ - search: (prev) => ({ - ...prev, - kind: undefined, - q: undefined, - }), - }) - } - /> - ) : ( - - ), - ) - .render()} -
    -
    + {isFirstRun ? ( + + + + ) : ( + <> + + + + + +
    + {toolbar} + {Result.builder(result) + .onInitial(() => ) + .onError((error) => ( + + )) + .onSuccess(() => + investigations.length === 0 ? ( + + void navigate({ + search: (prev) => ({ + ...prev, + kind: undefined, + q: undefined, + }), + }) + } + /> + ) : ( + + ), + ) + .render()} +
    +
    + + )}
    ) } +/* ------------------------------------------------------------------------------------------------- + * Triage strip + * -----------------------------------------------------------------------------------------------*/ + +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * Three numbers, above the fold: what is running, what is waiting on a human, + * and what the last day cost. Derived from the fetched page rather than a + * separate request — the page is the 100 most recent, which is the window these + * counts are about anyway. + */ +function TriageStrip({ investigations }: { investigations: ReadonlyArray }) { + const stats = useMemo(() => { + const running = investigations.filter((entry) => entry.status === "investigating") + const review = investigations.filter((entry) => entry.status === "diagnosed") + const cutoff = Date.now() - DAY_MS + const resolved = investigations.filter( + (entry) => entry.status === "resolved" && toEpochMs(entry.updated_at) >= cutoff, + ) + + const lensesInFlight = running.reduce((total, entry) => total + fanoutSize(entry), 0) + const critical = review.filter( + (entry) => (entry.severity ?? entry.snapshot.severity) === "critical", + ).length + + const durations = resolved + .map((entry) => + entry.diagnosed_at ? toEpochMs(entry.diagnosed_at) - toEpochMs(entry.created_at) : null, + ) + .filter((ms): ms is number => ms !== null && Number.isFinite(ms) && ms >= 0) + .sort((a, b) => a - b) + const median = durations.length > 0 ? durations[Math.floor(durations.length / 2)]! : null + const avgLenses = + resolved.length > 0 + ? resolved.reduce((total, entry) => total + fanoutSize(entry), 0) / resolved.length + : null + + return { running, review, resolved, lensesInFlight, critical, median, avgLenses } + }, [investigations]) + + return ( + // Same grid-with-border-cells reasoning as the detail page's impact strip: + // standalone divider elements strand themselves at the end of a row when the + // last stat wraps. +
    + + 0 + ? `${stats.critical} critical` + : "diagnosed, not resolved" + } + /> + +
    + ) +} + +function TriageStat({ + label, + dot, + labelTone, + value, + valueTone = "text-foreground", + detail, +}: { + label: string + dot: string + labelTone: string + value: number + valueTone?: string + detail: string +}) { + return ( +
    +
    + + + {label} + +
    +
    + + {value} + + {detail} +
    +
    + ) +} + +/* ------------------------------------------------------------------------------------------------- + * Empty states + * -----------------------------------------------------------------------------------------------*/ + +const HERO_SUGGESTIONS = [ + "Why did checkout latency spike this afternoon?", + "What changed before the last error burst?", + "Which service is slowest right now, and why?", +] + +/** + * The first-run page. A workspace with no investigations has nothing to filter, + * sort or triage, so the table chrome is all cost and no information — the whole + * page becomes the invitation instead. + */ +function HubHero({ onSubmit, busy }: { onSubmit: (title: string) => void | Promise; busy: boolean }) { + return ( +
    + + Investigations + +

    + Ask, and Maple goes and finds out. +

    +

    + It dispatches up to five agents, each attacking the problem from a different angle — deploys, + dependencies, saturation, traffic — then a validator ranks what they found and promotes one + answer. +

    + +
      + {HERO_SUGGESTIONS.map((suggestion) => ( +
    • + +
    • + ))} +
    +

    + + Maple also opens an investigation on its own whenever an incident fires — you will find those + here too. +

    +
    + ) +} + function HubEmptyState({ view, filtered, From acc72edf44a22e88a947052c86a4040c81d21098 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 6 Aug 2026 01:53:21 +0200 Subject: [PATCH 02/13] fix(investigations): actually dock the follow-up composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was the last child of the scroll area with `mt-auto`, which only reaches the bottom while the tab is shorter than the viewport. On any real diagnosis the content overflows, so the composer sat ~160px below the fold and scrolled away with the page — the opposite of docked, and squashed against the container edge once you did scroll to it. `Content` is a flex column, so the composer is now a `shrink-0` sibling below `Scroll` — the same shape as the sticky header above it. The tab scrolls behind it, it is on screen at every scroll position, and it keeps a 16px gutter. Drops `min-h-full` from the scrolled column with it: that was only there to give `mt-auto` something to push against, and it is the pattern that collapses tall cards inside a scroll area. --- .../investigations/follow-up-composer.tsx | 5 ++- .../investigations/investigation-view.tsx | 45 ++++++++++++------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/investigations/follow-up-composer.tsx b/apps/web/src/components/investigations/follow-up-composer.tsx index 53294cdfe..c0050e1da 100644 --- a/apps/web/src/components/investigations/follow-up-composer.tsx +++ b/apps/web/src/components/investigations/follow-up-composer.tsx @@ -1,7 +1,8 @@ import { ArrowUpIcon, ChatBubbleSparkleIcon } from "@/components/icons" /** - * The docked follow-up, pinned to the bottom of every document tab. + * The docked follow-up. The page pins it: it renders as a `shrink-0` sibling + * below the scroll area, so it stays put while the tab scrolls behind it. * * It looks like a composer but it is a button, and that is deliberate. The real * composer belongs to `ChatConversation`, which owns the session, the approval @@ -14,7 +15,7 @@ export function FollowUpComposer({ onSubmit }: { onSubmit: () => void }) {