feat: add investigation map workspace view

This commit is contained in:
2026-08-05 10:10:16 +01:00
parent a4dc165385
commit ce673b8eb4
5 changed files with 247 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Investigation Map — user-facing workspace card.
*
* Shows the progress of reasoning as a set of investigation topics with
* simple status indicators. Does NOT expose graph internals.
*
* Design principles:
* - Calm, spacious, accessible
* - No percentages, no progress bars, no confidence scores
* - Topics evolve naturally across turns
*/
import getInvestigationMapTopics from "@/lib/map/investigation-map-adapter";
/* ── Status icons (unicode — no icon library dependency) ─── */
const STATUS_ICONS = {
established: "✓",
current: "●",
unknown: "○",
};
function topicRowColor(status) {
switch (status) {
case "established":
return "text-gray-900";
case "current":
return "text-blue-800";
default:
return "text-gray-400";
}
}
function topicIconColor(status) {
switch (status) {
case "established":
return "text-green-600";
case "current":
return "text-blue-500";
default:
return "text-gray-300";
}
}
/* ── Single topic row ───────────────────────────────────── */
function TopicRow({ title, status }) {
const icon = STATUS_ICONS[status];
const colorClass = topicRowColor(status);
const iconColor = topicIconColor(status);
const ariaLabel = `${status === "established" ? "Established" : status === "current" ? "Currently investigating" : "Still to explore"}: ${title}`;
return (
<div
className={`flex items-center gap-3 py-2.5 text-sm transition-opacity duration-300 ease-in-out ${colorClass}`}
aria-label={ariaLabel}
role="listitem"
data-testid={`map-topic-${status === "established" ? "established" : status === "current" ? "current" : "unknown"}`}
>
<span className={`flex-none text-base ${iconColor} leading-none`} aria-hidden="true">
{icon}
</span>
<span className="flex-1">{title}</span>
</div>
);
}
/* ── Card wrapper ────────────────────────────────────────── */
export default function InvestigationMap({ turnCount = 0 }) {
const topics = getInvestigationMapTopics(turnCount);
// Group topics by status for cleaner rendering
const groups = {
established: topics.filter((t) => t.status === "established"),
current: topics.filter((t) => t.status === "current"),
unknown: topics.filter((t) => t.status === "unknown"),
};
// Only render the card if there are non-established topics (during active investigation)
const hasActiveTopics = groups.current.length > 0 || groups.unknown.length > 0;
if (!hasActiveTopics && groups.established.length === 0) return null;
return (
<div className="rounded-lg border border-gray-200 bg-white p-5" role="region" aria-label="Investigation map">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wide text-gray-500">
Investigation Map
</h2>
<p className="mb-3 text-xs text-gray-400">
We are building an understanding of your situation one question at a time.
</p>
<div className="space-y-px border-t border-gray-100 pt-3" role="list" aria-label="Investigation topics">
{topics.map((topic, i) => (
<TopicRow key={`${topic.title}-${i}`} title={topic.title} status={topic.status} />
))}
</div>
</div>
);
}
+6
View File
@@ -5,6 +5,7 @@ import DiagnosticsView from "@/components/diagnostics-view";
import GraphUpdateView from "@/components/graph-update-view";
import SituationGraphView from "@/components/situation-graph-view";
import InvestigationSummaryPanel from "@/components/investigation-summary-panel";
import InvestigationMap from "@/components/investigation-map";
// ── Technical summary detector (main view filters these) ───
const TECHNICAL_PATTERNS = [
@@ -733,6 +734,11 @@ export default function ReasoningWorkspace({
<CurrentUnderstandingCard currentSummary={graph?.currentSummary || result?.updatedSituationGraph?.currentSummary} plainLanguage={propUnderstanding || null} />
)}
{/* ── Investigation Map (visible during active investigation) ─ */}
{hasSelectedQuestion && (
<InvestigationMap turnCount={investigationHistory.length} />
)}
<OriginalSituation scenario={scenario} centralStatement={graph?.centralStatement} />
{investigationHistory.length > 0 && <InvestigationHistory turns={investigationHistory} />}
+7
View File
@@ -97,6 +97,13 @@ temporary workaround and the desired eventual contract.
| OriginalSituation | Central statement display | `scenario` prop (user input) or `graph.centralStatement` | Engine-derived central statement from user input | Start case | UI already handles both; engine should normalise |
| DeveloperDetails | Node descriptions | Mock nodes have `label === description` | Distinct, detailed description per node | Graph construction | Current mock uses label as description; separate fields needed |
## Investigation Map (Workspace UX)
| Feature | UI need | Temporary mock | Desired reasoning output | Likely stage | Notes |
| ---------------------- | ---------------------------------- | --------------------------------------------------- | -------------------------------------------------------------- | ---------------------- | -------------------------------------------------------- |
| InvestigationMap | Visible investigation progress | Mock topic set with manual turn-based status progression | Engine emits `investigationTopics: [{ title, status, ordering?, evidenceCount? }]` | Each turn — start and update response | UI displays topics in engine-determined order; statuses: "established" / "current" / "unknown" |
| InvestigationMap | Topic status evolution across turns | Hardcoded PROGRESSION array indexed by `investigationHistory.length` | Engine determines which topics are established, active, or unknown at each turn | Question selection phase | Topics should not expose graph internals; plain-language labels only |
## Open Questions / Future Work
1. **Structured confidence scores**: The UI currently mocks ordinal confidence (low/medium/high). The reasoning engine should eventually emit numeric confidence values per node and a computed conclusion confidence, enabling richer visual indicators.
+63
View File
@@ -0,0 +1,63 @@
/**
* Investigation Map Mock Adapter
*
* Provides the Investigation Map with a set of investigation topics and their
* current status (established / current / unknown).
*
* TODO: Replace this mock adapter when the reasoning engine emits real
* investigation data. The eventual contract should provide:
* - `investigationTopics`: [{ title, status, evidenceCount? }]
* - `topicStatus` values: "established" | "current" | "unknown"
* - `topicOrdering`: the reasoning-engine-determined sequence
* - `evidenceCount`: optional count of supporting evidence per topic
*
* Until then, this adapter drives a realistic mock progression across turns.
*/
/** @type {Array<{ title: string }>} */
const TOPICS = [
{ title: "Central situation" },
{ title: "Complaint trend direction" },
{ title: "Measurement basis" },
{ title: "Production volume context" },
{ title: "QA process changes" },
{ title: "Product change log" },
{ title: "Support response patterns" },
{ title: "Prior similar cases" },
];
/**
* Status progression per turn index.
* The reasoning engine will eventually determine these values.
*/
const PROGRESSION = [
// Turn 0 — initial analysis just started
["established", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown", "unknown"],
// Turn 1 — first question answered
["established", "established", "current", "unknown", "unknown", "unknown", "unknown", "unknown"],
// Turn 2 — second question answered
["established", "established", "established", "current", "unknown", "unknown", "unknown", "unknown"],
// Turn 3 — third question answered
["established", "established", "established", "established", "current", "unknown", "unknown", "unknown"],
// Turn 4 — fourth question answered
["established", "established", "established", "established", "established", "current", "unknown", "unknown"],
// Turn 5 — fifth question answered
["established", "established", "established", "established", "established", "established", "current", "unknown"],
// Turn 6+ — final turn
["established", "established", "established", "established", "established", "established", "established", "current"],
];
/**
* Get investigation map topics for a given turn index.
* @param {number} turnIndex - Zero-based turn number (0 = initial analysis).
* @returns {{ title: string, status: 'established' | 'current' | 'unknown' }[]}
*/
export function getInvestigationMapTopics(turnIndex) {
const idx = Math.min(turnIndex, PROGRESSION.length - 1);
return TOPICS.map((topic, i) => ({
title: topic.title,
status: PROGRESSION[idx][i],
}));
}
export default getInvestigationMapTopics;
+71
View File
@@ -367,4 +367,75 @@ test("update submission replaces response panel with loading card, preserves con
await expect(rwTextarea).toBeVisible();
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
}
});
/* ═══════ Test: investigation map appears and evolves across turns ═ */
test("investigation map: appears after start, topics evolve across mocked turns", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
window.__MOCK_ENABLED = true;
window.__MOCK_DELAY = "instant";
window.__MOCK_SCENARIO = "complete";
});
const textarea = page.locator("textarea[placeholder*=Describe]");
await textarea.fill(INVESTIGATION_SCENARIOS[0].centralStatement);
await page.getByRole("button", { name: "Analyse" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Verify the investigation map card is present after start
const mapCard = page.locator('[aria-label="Investigation map"]');
await expect(mapCard).toBeVisible();
// Map heading visible
await expect(mapCard.getByRole("heading", { name: "Investigation Map" })).toBeVisible();
// Helper text visible
await expect(mapCard.getByText(/building an understanding/i)).toBeVisible();
// At turn 0 (initial analysis): one topic established, one current, rest unknown
const establishedTopics = page.locator('[data-testid="map-topic-established"]');
const currentTopics = page.locator('[data-testid="map-topic-current"]');
const unknownTopics = page.locator('[data-testid="map-topic-unknown"]');
await expect(establishedTopics).toHaveCount(1);
await expect(currentTopics).toHaveCount(1);
await expect(unknownTopics).toHaveCount(6);
// Submit first answer → turn 1: one more established, next topic becomes current
const answerTextarea = page.locator('textarea[placeholder*=Answer]');
if (await answerTextarea.isVisible({ timeout: 3000 })) {
await answerTextarea.fill("The figures are comparable.");
await page.getByRole("button", { name: "Update situation" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// Current investigation remains visible (context preserved)
await expect(page.getByRole("heading", { name: "Current investigation" })).toBeVisible({ timeout: 5_000 });
// Map should update: 2 established, 1 current, 5 unknown
await expect(establishedTopics).toHaveCount(2);
await expect(currentTopics).toHaveCount(1);
await expect(unknownTopics).toHaveCount(5);
// Submit second answer → turn 2
const answerTextarea2 = page.locator('textarea[placeholder*=Answer]');
if (await answerTextarea2.isVisible({ timeout: 3000 })) {
await answerTextarea2.fill("Complaint rate fell from 2.0 to 1.9 per 100 units.");
await page.getByRole("button", { name: "Update situation" }).click();
await waitForLoading(page);
await waitForWorkspaceReady(page);
// After second update: 3 established, current shifts again
await expect(establishedTopics).toHaveCount(3);
await expect(currentTopics).toHaveCount(1);
await expect(unknownTopics).toHaveCount(4);
}
}
});