From 4f7317c2ff2212af94531a695f84f1841169bf7e Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 25 Jun 2026 12:04:48 -0400 Subject: [PATCH 1/3] Polish Ask and Home with decision detail, filters, and story hierarchy. Add a decision detail side panel, client-side Ask filters and trust-weighted sorting, and clearer what/why/who/systems presentation in StoryStrip. Co-authored-by: Cursor --- .../components/memory/DecisionDetailPanel.tsx | 99 ++++++++++ frontend/src/components/ui/StoryStrip.tsx | 22 ++- frontend/src/views/AskView.tsx | 176 ++++++++++++++--- frontend/src/views/ExploreView.tsx | 179 ++++++++++++------ 4 files changed, 383 insertions(+), 93 deletions(-) create mode 100644 frontend/src/components/memory/DecisionDetailPanel.tsx diff --git a/frontend/src/components/memory/DecisionDetailPanel.tsx b/frontend/src/components/memory/DecisionDetailPanel.tsx new file mode 100644 index 0000000..598e3f5 --- /dev/null +++ b/frontend/src/components/memory/DecisionDetailPanel.tsx @@ -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 { + const url = buildDecisionShareUrl(decision!.event_id); + try { + await navigator.clipboard.writeText(url); + showToast("Decision link copied"); + } catch { + showToast("Could not copy link"); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/ui/StoryStrip.tsx b/frontend/src/components/ui/StoryStrip.tsx index 5cd4e74..3b3fe9d 100644 --- a/frontend/src/components/ui/StoryStrip.tsx +++ b/frontend/src/components/ui/StoryStrip.tsx @@ -1,5 +1,5 @@ import type { DecisionResult } from "../../types"; -import { formatSource, truncate } from "../../lib/format"; +import { formatSource, scorePercent } from "../../lib/format"; type Props = { decision: DecisionResult; @@ -7,34 +7,40 @@ type Props = { }; /** - * 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 (
+
+

Top match

+

+ Trust {scorePercent(decision.trust_score)}% · Impact{" "} + {scorePercent(decision.importance_score)}% +

+
What happened -

{truncate(decision.content, 140)}

+

{decision.content}

Why -

{truncate(why, 120)}

+

{why}

Who decided -

{who}

+

{who}

Systems affected -

{systems}

+

{systems}

diff --git a/frontend/src/views/AskView.tsx b/frontend/src/views/AskView.tsx index cf951dd..78e22ee 100644 --- a/frontend/src/views/AskView.tsx +++ b/frontend/src/views/AskView.tsx @@ -3,12 +3,15 @@ import { queryMemory } from "../api/client"; import { useApp } from "../context/AppContext"; import { summarizeQueryResults } from "../lib/assistant"; import { isUnauthorizedMessage } from "../lib/auth"; +import { scorePercent } from "../lib/format"; import { DecisionCard } from "../components/memory/DecisionCard"; +import { DecisionDetailPanel } from "../components/memory/DecisionDetailPanel"; import { WorkspaceBar } from "../components/layout/WorkspaceBar"; import { PageHeader } from "../components/ui/PageHeader"; import { StoryStrip } from "../components/ui/StoryStrip"; import { Skeleton } from "../components/ui/Skeleton"; import { StateView } from "../components/ui/StateView"; +import { IconEmpty } from "../components/ui/icons"; const EXAMPLES = [ "Why CockroachDB for payments?", @@ -17,6 +20,14 @@ const EXAMPLES = [ ]; const ENTITY_PREFIXES = ["person:", "system:"]; +const SOURCE_FILTERS = ["all", "slack", "github", "jira", "linear"] as const; +type SourceFilter = (typeof SOURCE_FILTERS)[number]; + +function sortByRank(a: { importance_score: number; trust_score: number }, b: typeof a): number { + const rankA = a.importance_score * a.trust_score; + const rankB = b.importance_score * b.trust_score; + return rankB - rankA; +} export function AskView() { const { @@ -27,19 +38,58 @@ export function AskView() { setSelectedDecisionId, setView, lastQuery, + pendingAskQuery, + setPendingAskQuery, + detailDecisionId, + setDetailDecisionId, } = useApp(); const [query, setQuery] = useState("Why CockroachDB for payments?"); const [limit, setLimit] = useState(8); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [sourceFilter, setSourceFilter] = useState("all"); + const [minTrust, setMinTrust] = useState(0); - // Debounce the character counter so rapid typing doesn't churn React. const [debouncedLength, setDebouncedLength] = useState(query.trim().length); useEffect(() => { const handle = window.setTimeout(() => setDebouncedLength(query.trim().length), 200); return () => window.clearTimeout(handle); }, [query]); + useEffect(() => { + if (!pendingAskQuery || pendingAskQuery.length < 3) return; + const q = pendingAskQuery; + setPendingAskQuery(null); + setQuery(q); + setLoading(true); + setError(null); + void queryMemory({ + query: q, + workspace_id: workspaceId.trim() || "local-dev", + limit, + }) + .then((result) => { + setLastQuery(result); + setExploreDecisions(result.results); + if (result.results[0]) setSelectedDecisionId(result.results[0].event_id); + pushMessage("user", q); + pushMessage("assistant", summarizeQueryResults(result)); + }) + .catch((e) => { + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => setLoading(false)); + }, [ + pendingAskQuery, + setPendingAskQuery, + workspaceId, + limit, + setLastQuery, + setExploreDecisions, + setSelectedDecisionId, + pushMessage, + ]); + const search = useCallback(async () => { const q = query.trim(); if (q.length < 3) { @@ -76,34 +126,37 @@ export function AskView() { const handleCardSelect = useCallback( (id: string) => { - // Chip clicks pass `person:` / `system:`; keep the original decision - // focused so the memory map highlights the right node. if (ENTITY_PREFIXES.some((p) => id.startsWith(p))) { const firstId = lastQuery?.results[0]?.event_id; if (firstId) setSelectedDecisionId(firstId); } else { setSelectedDecisionId(id); } - setView("explore"); + const decisionId = + id.startsWith("person:") || id.startsWith("system:") ? undefined : id; + setView("explore", decisionId ? { decision: decisionId } : undefined); }, [lastQuery, setSelectedDecisionId, setView], ); - const results = lastQuery?.results ?? []; - const resultList = useMemo( - () => - results.map((d, i) => ( - - )), - [results, handleCardSelect], - ); + const rawResults = lastQuery?.results ?? []; + const filteredResults = useMemo(() => { + let list = [...rawResults]; + if (sourceFilter !== "all") { + list = list.filter((d) => d.source.toLowerCase().includes(sourceFilter)); + } + if (minTrust > 0) { + list = list.filter((d) => d.trust_score >= minTrust); + } + return list.sort(sortByRank); + }, [rawResults, sourceFilter, minTrust]); - const topResult = results[0]; + const detailDecision = + filteredResults.find((d) => d.event_id === detailDecisionId) ?? + rawResults.find((d) => d.event_id === detailDecisionId) ?? + null; + + const topResult = filteredResults[0] ?? rawResults[0]; return (
@@ -176,7 +229,7 @@ export function AskView() {
{error ? ( - + {error} {isUnauthorizedMessage(error) ? (

Open Connection above and save your API key.

@@ -201,7 +254,7 @@ export function AskView() { decision={topResult} onExplore={() => { setSelectedDecisionId(topResult.event_id); - setView("explore"); + setView("explore", { decision: topResult.event_id }); }} /> ) : null} @@ -210,21 +263,82 @@ export function AskView() {

- {lastQuery.total} result{lastQuery.total === 1 ? "" : "s"} ·{" "} - {lastQuery.latency_ms}ms + {filteredResults.length} shown · {lastQuery.total} total · {lastQuery.latency_ms}ms + {typeof lastQuery.coverage_score === "number" ? ( + <> + {" "} + · coverage{" "} + + {Math.round(lastQuery.coverage_score * 100)}% + + + ) : null}

-
- {results.length === 0 ? ( - - Try a broader query, or run make demo to seed example decisions - for local-dev. - - ) : ( -
{resultList}
- )} + +
+ {SOURCE_FILTERS.map((src) => ( + + ))} + +
+ +
+
+ {filteredResults.length === 0 ? ( + } title="No memories matched filters"> + Broaden your query or lower the trust threshold. If this workspace is new, capture + decisions from your tools or the AI agents view. + + ) : ( + filteredResults.map((d, i) => ( + + )) + )} +
+ setDetailDecisionId(null)} + onExplore={(id) => { + setSelectedDecisionId(id); + setView("explore", { decision: id }); + }} + /> +
) : null} diff --git a/frontend/src/views/ExploreView.tsx b/frontend/src/views/ExploreView.tsx index bf58e4d..fa92025 100644 --- a/frontend/src/views/ExploreView.tsx +++ b/frontend/src/views/ExploreView.tsx @@ -1,13 +1,17 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useApp } from "../context/AppContext"; +import { fetchContradictions } from "../api/client"; import { MemoryGraph } from "../components/memory/MemoryGraph"; import { TimelineView } from "../components/memory/TimelineView"; import { LineageView } from "../components/memory/LineageView"; import { DecisionCard } from "../components/memory/DecisionCard"; +import { DecisionDetailPanel } from "../components/memory/DecisionDetailPanel"; import { WorkspaceBar } from "../components/layout/WorkspaceBar"; import { PageHeader } from "../components/ui/PageHeader"; import { StateView } from "../components/ui/StateView"; +import { IconGraph } from "../components/ui/icons"; import { resolveDecisionFocus } from "../lib/decision"; +import { parseHash } from "../lib/routing"; type ExploreTab = "graph" | "timeline" | "lineage"; @@ -19,30 +23,63 @@ export function ExploreView() { lastQuery, workspaceId, setView, + detailDecisionId, + setDetailDecisionId, } = useApp(); const [tab, setTab] = useState("graph"); + const [conflictCount, setConflictCount] = useState(0); const focusId = selectedDecisionId ?? exploreDecisions[0]?.event_id ?? null; const decisions = exploreDecisions.length > 0 ? exploreDecisions : lastQuery?.results ?? []; + const focusOnly = decisions.length === 0 && Boolean(selectedDecisionId); + + useEffect(() => { + const route = parseHash(); + if (route.decisionId) { + setSelectedDecisionId(route.decisionId); + setDetailDecisionId(route.decisionId); + } + }, [setSelectedDecisionId, setDetailDecisionId]); + + useEffect(() => { + if (focusOnly) setTab("lineage"); + }, [focusOnly, selectedDecisionId]); + + useEffect(() => { + const ws = workspaceId.trim() || "local-dev"; + void fetchContradictions(ws) + .then((items) => setConflictCount(items.length)) + .catch(() => setConflictCount(0)); + }, [workspaceId]); const handleCardSelect = useCallback( (id: string) => { - setSelectedDecisionId(resolveDecisionFocus(id, decisions, focusId)); + const next = resolveDecisionFocus(id, decisions, focusId); + if (next) { + setSelectedDecisionId(next); + setDetailDecisionId(next); + } if (id.startsWith("person:") || id.startsWith("system:")) { setTab("graph"); } }, - [decisions, focusId, setSelectedDecisionId], + [decisions, focusId, setSelectedDecisionId, setDetailDecisionId], ); const handleTimelineSelect = useCallback( (decisionId: string) => { setSelectedDecisionId(decisionId); + setDetailDecisionId(decisionId); setTab("graph"); }, - [setSelectedDecisionId], + [setSelectedDecisionId, setDetailDecisionId], ); + const detailDecision = + decisions.find((d) => d.event_id === detailDecisionId) ?? + (focusId ? decisions.find((d) => d.event_id === focusId) : null) ?? + null; + return (
0 ? (

- Showing {decisions.length} result{decisions.length === 1 ? "" : "s"} for{" "} - {lastQuery.query} + Showing {decisions.length} decision{decisions.length === 1 ? "" : "s"} + {conflictCount > 0 ? ( + <> + {" "} + · {conflictCount} open conflict{conflictCount === 1 ? "" : "s"} + + ) : null}{" "} + for {lastQuery.query} {lastQuery.latency_ms ? <> · {lastQuery.latency_ms}ms : null}

) : null} - {decisions.length === 0 ? ( + {decisions.length === 0 && !selectedDecisionId ? ( } title="No memories to map yet" action={ } > @@ -89,56 +132,84 @@ export function ExploreView() { onClick={() => setTab(t)} > {t === "graph" ? "Relationships" : t === "timeline" ? "Timeline" : "Lineage"} + {t === "graph" && decisions.length > 0 ? ( + {decisions.length} + ) : null} + {t === "timeline" && conflictCount > 0 ? ( + {conflictCount} + ) : null} ))} -
- {tab === "graph" ? ( - { - const next = resolveDecisionFocus(id, decisions, focusId); - if (next) setSelectedDecisionId(next); - }} - /> - ) : null} - {tab === "timeline" ? ( - - ) : null} - {tab === "lineage" && focusId ? ( - - ) : null} - {tab === "lineage" && !focusId ? ( - - Pick a decision below to trace supersession and trigger lineage. - - ) : null} -
- -
-

Decisions in this view

-
- {decisions.map((d) => ( - +
+ {tab === "graph" ? ( + { + const next = resolveDecisionFocus(id, decisions, focusId); + if (next) { + setSelectedDecisionId(next); + setDetailDecisionId(next); + } + }} /> - ))} -
-
+ ) : null} + {tab === "timeline" ? ( + + ) : null} + {tab === "lineage" && focusId ? ( + { + setSelectedDecisionId(id); + setDetailDecisionId(id); + }} + /> + ) : null} + {tab === "lineage" && !focusId ? ( + + Pick a decision below to trace supersession and trigger lineage. + + ) : null} + + + setDetailDecisionId(null)} + onExplore={(id) => setSelectedDecisionId(id)} + /> + + + {decisions.length > 0 ? ( +
+

Decisions in this view

+
+ {decisions.map((d) => ( + + ))} +
+
+ ) : focusOnly && focusId ? ( +

+ Tracing lineage for decision {focusId.slice(0, 8)}… — run{" "} + Search to populate the relationship graph. +

+ ) : null} )}
From 8984b2f998a0899d7ff26a25dbd6e05b515e41a1 Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 25 Jun 2026 13:13:54 -0400 Subject: [PATCH 2/3] fix(frontend): add DecisionCard onDetail prop for Ask detail panel PR21 AskView uses onDetail before PR22 lands; include the prop on DecisionCard so the stacked branch builds independently. Co-authored-by: Cursor --- .../src/components/memory/DecisionCard.tsx | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/memory/DecisionCard.tsx b/frontend/src/components/memory/DecisionCard.tsx index 97a798d..936d450 100644 --- a/frontend/src/components/memory/DecisionCard.tsx +++ b/frontend/src/components/memory/DecisionCard.tsx @@ -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 { + try { + await navigator.clipboard.writeText(buildDecisionShareUrl(d.event_id)); + showToast("Decision link copied"); + } catch { + showToast("Could not copy link"); + } + } return (
@@ -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); + }} > {d.event_type}

{d.content}

@@ -90,11 +113,10 @@ function DecisionCardInner({ decision: d, defaultOpen, onSelect, selected }: Pro ) : null}
- +
@@ -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 ); }); From c378accd28cb0a89c50512a4075ac512dcb63a0f Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 25 Jun 2026 13:22:23 -0400 Subject: [PATCH 3/3] fix(e2e): match Ask results heading copy after UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskView renders "1 shown · N total" in the results h2, not "1 result". Update the lineage journey assertion so mocked-search e2e passes without a live backend. Co-authored-by: Cursor --- frontend/e2e/journeys.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/journeys.spec.ts b/frontend/e2e/journeys.spec.ts index 60169ae..9c14649 100644 --- a/frontend/e2e/journeys.spec.ts +++ b/frontend/e2e/journeys.spec.ts @@ -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();