Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/e2e/journeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ test.describe("Critical user journeys", () => {
await page.goto("/#ask");
await page.getByLabel(/your question/i).fill("Why CockroachDB for payments?");
await page.getByRole("button", { name: /search memory/i }).click();
await expect(page.getByRole("heading", { name: /1 result/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /1 shown/i })).toBeVisible({
timeout: 10_000,
});

await page.getByRole("button", { name: /open memory map/i }).click();
await expect(page.getByRole("heading", { name: /memory map/i })).toBeVisible();
Expand Down
41 changes: 31 additions & 10 deletions frontend/src/components/memory/DecisionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
import { memo, useId, useState } from "react";
import type { DecisionResult } from "../../types";
import { formatRelativeTime, formatSource } from "../../lib/format";
import { buildDecisionShareUrl } from "../../lib/routing";
import { useToast } from "../ui/Toast";
import { DecisionScores } from "./DecisionScores";
import { IconLink } from "../ui/icons";

type Props = {
decision: DecisionResult;
defaultOpen?: boolean;
onSelect?: (id: string) => void;
onDetail?: (id: string) => void;
selected?: boolean;
};

function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Props) {
function DecisionCardInner({
decision: d,
defaultOpen,
onSelect,
onDetail,
selected,
}: Props) {
const [open, setOpen] = useState(!!defaultOpen);
const panelId = useId();
const { showToast } = useToast();

async function copyLink(): Promise<void> {
try {
await navigator.clipboard.writeText(buildDecisionShareUrl(d.event_id));
showToast("Decision link copied");
} catch {
showToast("Could not copy link");
}
}

return (
<article className={`decision-card fade-in ${selected ? "decision-card--selected" : ""}`}>
Expand All @@ -21,7 +41,10 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro
className="decision-card__header"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
onClick={() => {
setOpen((v) => !v);
onDetail?.(d.event_id);
}}
>
<span className="decision-card__type">{d.event_type}</span>
<h3 className="decision-card__title">{d.content}</h3>
Expand Down Expand Up @@ -90,11 +113,10 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro
) : null}

<footer className="decision-card__footer">
<button
type="button"
className="btn-text"
onClick={() => onSelect?.(d.event_id)}
>
<button type="button" className="btn-text" onClick={() => void copyLink()}>
<IconLink size={14} aria-hidden /> Copy link
</button>
<button type="button" className="btn-text" onClick={() => onSelect?.(d.event_id)}>
View in memory map →
</button>
</footer>
Expand All @@ -104,13 +126,12 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro
);
}

// Lists of 10+ cards re-rendered on unrelated context updates; memo keeps the
// expensive markup stable when neither the decision nor the selection changes.
export const DecisionCard = memo(DecisionCardInner, (prev, next) => {
return (
prev.decision === next.decision &&
prev.selected === next.selected &&
prev.defaultOpen === next.defaultOpen &&
prev.onSelect === next.onSelect
prev.onSelect === next.onSelect &&
prev.onDetail === next.onDetail
);
});
99 changes: 99 additions & 0 deletions frontend/src/components/memory/DecisionDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { DecisionResult } from "../../types";
import { formatRelativeTime, formatSource, scorePercent } from "../../lib/format";
import { DecisionScores } from "./DecisionScores";
import { buildDecisionShareUrl } from "../../lib/routing";
import { useToast } from "../ui/Toast";
import { IconLink } from "../ui/icons";

type Props = {
decision: DecisionResult | null;
onClose: () => void;
onExplore?: (id: string) => void;
};

/** Side panel with full decision provenance and scores. */
export function DecisionDetailPanel({ decision, onClose, onExplore }: Props) {
const { showToast } = useToast();

if (!decision) return null;

async function copyLink(): Promise<void> {
const url = buildDecisionShareUrl(decision!.event_id);
try {
await navigator.clipboard.writeText(url);
showToast("Decision link copied");
} catch {
showToast("Could not copy link");
}
}

return (
<aside className="detail-panel" role="complementary" aria-label="Decision details">
<header className="detail-panel__head">
<h2 className="detail-panel__title">Decision detail</h2>
<button type="button" className="btn btn--ghost" onClick={onClose} aria-label="Close panel">
Close
</button>
</header>
<p className="detail-panel__content">{decision.content}</p>
<div className="detail-panel__meta">
<span className="badge badge--source">{formatSource(decision.source)}</span>
{decision.channel ? <span className="badge badge--muted">{decision.channel}</span> : null}
<span className={`badge badge--status badge--${decision.status}`}>{decision.status}</span>
<span className="decision-card__time">{formatRelativeTime(decision.extracted_at)}</span>
</div>
<DecisionScores
importance_score={decision.importance_score}
trust_score={decision.trust_score}
extraction_confidence={decision.extraction_confidence}
/>
{decision.made_by.length > 0 ? (
<section className="decision-card__section">
<h4>Who decided</h4>
<ul className="chip-list">
{decision.made_by.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</section>
) : null}
{decision.affects.length > 0 ? (
<section className="decision-card__section">
<h4>Systems affected</h4>
<ul className="chip-list chip-list--accent">
{decision.affects.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</section>
) : null}
{decision.rationale.length > 0 ? (
<section className="decision-card__section">
<h4>Why this matters</h4>
<ul className="rationale-list">
{decision.rationale.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
</section>
) : null}
<footer className="detail-panel__actions">
<button type="button" className="btn btn--secondary" onClick={() => void copyLink()}>
<IconLink size={16} aria-hidden /> Copy link
</button>
{onExplore ? (
<button
type="button"
className="btn btn--primary"
onClick={() => onExplore(decision.event_id)}
>
Open in memory map
</button>
) : null}
</footer>
<p className="muted detail-panel__scores">
Trust {scorePercent(decision.trust_score)}% · Impact {scorePercent(decision.importance_score)}%
</p>
</aside>
);
}
22 changes: 14 additions & 8 deletions frontend/src/components/ui/StoryStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,46 @@
import type { DecisionResult } from "../../types";
import { formatSource, truncate } from "../../lib/format";
import { formatSource, scorePercent } from "../../lib/format";

type Props = {
decision: DecisionResult;
onExplore?: () => void;
};

/**
* Product storytelling frame: what / why / who / systems / next.
* Surfaces decision narrative without exposing raw technical fields.
* Product storytelling frame: what / why / who / systems.
*/
export function StoryStrip({ decision, onExplore }: Props) {
const who = decision.made_by.length
? decision.made_by.map((p) => p.split("@")[0]).join(", ")
: "Unknown";
: "Not captured";
const systems = decision.affects.length ? decision.affects.join(", ") : "None listed";
const why = decision.rationale[0] ?? "Rationale not captured yet.";

return (
<section className="story-strip" aria-label="Decision summary">
<header className="story-strip__head">
<h2 className="story-strip__title">Top match</h2>
<p className="story-strip__scores muted">
Trust {scorePercent(decision.trust_score)}% · Impact{" "}
{scorePercent(decision.importance_score)}%
</p>
</header>
<div className="story-strip__grid">
<article className="story-pill story-pill--what">
<span className="story-pill__label">What happened</span>
<p>{truncate(decision.content, 140)}</p>
<p className="story-pill__value">{decision.content}</p>
</article>
<article className="story-pill story-pill--why">
<span className="story-pill__label">Why</span>
<p>{truncate(why, 120)}</p>
<p className="story-pill__value">{why}</p>
</article>
<article className="story-pill story-pill--who">
<span className="story-pill__label">Who decided</span>
<p>{who}</p>
<p className="story-pill__value">{who}</p>
</article>
<article className="story-pill story-pill--systems">
<span className="story-pill__label">Systems affected</span>
<p>{systems}</p>
<p className="story-pill__value">{systems}</p>
</article>
</div>
<footer className="story-strip__foot">
Expand Down
Loading
Loading