feat: add investigation map workspace view
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user