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
6 changes: 6 additions & 0 deletions CHANGELOG.d/2.12.6-lineage-dag-stories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Verify lineage DAG states before changing graph CSS

Open `Lineage/LineageDag` in Storybook and compare the empty, grouped/forked,
ungrouped, and long-title states. The graph now omits a relationship from both
the SVG and its count unless both endpoints are visible in the same reconstruct
group.
17 changes: 17 additions & 0 deletions docs/adr/0078-focused-lineage-graph-on-post-open.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ global graph only as the initial loading fallback. The graph is an
interaction-time projection; it does not alter stored lineage edges or
analysis-run snapshots.

The layout boundary independently keeps only edges whose source and target
nodes are both present in the same visible reconstruct group. A dangling edge
or an edge crossing visible groups is omitted from both the SVG and its edge
count. This preserves the backend's eligibility/ABAC decision when a partial
or stale client payload reaches the renderer; an invisible relationship must
not survive as buyer-facing aggregate evidence.

Within one visible group, the layout may receive a converging DAG or a cyclic
import. It retains every visible edge, positions a shared child once on the
first deterministic walk, and excludes already-positioned or active-path
children from recursive re-entry. This prevents non-termination and repeated
placement without rewriting the stored graph.

## Consequences

- Opening a related post shows all visible nodes in its connected lineage
Expand All @@ -33,6 +46,10 @@ analysis-run snapshots.
lineage" state rather than an unrelated DAG.
- A focused component can be larger than the landing limit, so the endpoint
remains authenticated and ABAC-filtered.
- Captions count the same authorized, renderable edges that the buyer can see;
a relationship to an omitted node cannot leak through a count.
- A converging child has one stable SVG position while every authorized parent
edge remains visible.

## Alternatives rejected

Expand Down
1 change: 1 addition & 0 deletions docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ buyer-facing control you can click before changing product CSS.
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` |
| `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` |

Repeated web objects must use `frontend/src/styles/tokens.css` and a module
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/LineageDag.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LineageDag } from "./LineageDag";
import type { LineageGraph } from "./api";

const meta = {
title: "Lineage/LineageDag",
component: LineageDag,
args: {
onSelectPost: () => undefined,
},
} satisfies Meta<typeof LineageDag>;

export default meta;

type Story = StoryObj<typeof meta>;

// Edge case: no reconstructed lineage yet -- must not render an empty SVG.
export const Empty: Story = {
args: {
graph: { nodes: [], edges: [] },
},
};

export const SingleBranch: Story = {
args: {
graph: {
nodes: [
{ id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
{ id: "a2", group: "Northwind Renewal", label: "Follow-up call", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false },
{ id: "a3", group: "Northwind Renewal", label: "Contract signed", occurred_at: "2026-01-10T00:00:00Z", is_root: false, is_branch_point: false },
],
edges: [
{ source: "a1", target: "a2", fused_score: 0.86 },
{ source: "a2", target: "a3", fused_score: 0.91 },
],
} satisfies LineageGraph,
},
};

// The multi-branch, git-branch-style case the Ask Agent answer view relies on:
// several independent lineage threads rendered as separate figures, plus one
// thread with an actual fork (a branch point with two children).
export const MultipleGitStyleBranches: Story = {
args: {
graph: {
nodes: [
{ id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: true },
{ id: "a2", group: "Northwind Renewal", label: "Legal review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false },
{ id: "a3", group: "Northwind Renewal", label: "Pricing review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false },
{ id: "b1", group: "Acme Onboarding", label: "Welcome call", occurred_at: "2026-02-01T00:00:00Z", is_root: true, is_branch_point: false },
{ id: "b2", group: "Acme Onboarding", label: "Access provisioned", occurred_at: "2026-02-03T00:00:00Z", is_root: false, is_branch_point: false },
],
edges: [
{ source: "a1", target: "a2", fused_score: 0.78 },
{ source: "a1", target: "a3", fused_score: 0.64 },
{ source: "b1", target: "b2", fused_score: 0.9 },
],
} satisfies LineageGraph,
currentPostId: "a1",
},
};

// Edge case: a node with no explicit group (or a raw UUID group id from a
// partially-processed corpus) must still render, bucketed under "Ungrouped".
export const UngroupedNode: Story = {
args: {
graph: {
nodes: [{ id: "u1", group: "", label: "Standalone note with no thread yet", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }],
edges: [],
} satisfies LineageGraph,
},
};

// Edge case: a very long post title must truncate in the graph without
// breaking layout, while the full title stays available to screen readers.
export const LongNodeLabel: Story = {
args: {
graph: {
nodes: [
{
id: "a1",
group: "Northwind Renewal",
label: "Quarterly vendor renewal kickoff call with legal, procurement, and finance stakeholders",
occurred_at: "2026-01-01T00:00:00Z",
is_root: true,
is_branch_point: false,
},
],
edges: [],
} satisfies LineageGraph,
},
};
132 changes: 132 additions & 0 deletions frontend/src/LineageDag.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LineageDag } from "./LineageDag";
import type { LineageGraph } from "./api";

describe("LineageDag", () => {
it("shows an empty-state message instead of an empty graph", () => {
render(<LineageDag graph={{ nodes: [], edges: [] }} onSelectPost={vi.fn()} />);
expect(screen.getByText("No reconstructed lineage yet. Rebuild after seeding posts.")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});

it("renders one branch figure per lineage group, git-branch style", () => {
const graph: LineageGraph = {
nodes: [
{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: true },
{ id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false },
{ id: "b1", group: "Project Beta", label: "Beta kickoff", occurred_at: "2026-01-03T00:00:00Z", is_root: true, is_branch_point: false },
],
edges: [{ source: "a1", target: "a2", fused_score: 0.82 }],
};
const { container } = render(<LineageDag graph={graph} onSelectPost={vi.fn()} />);

expect(screen.getByText("Project Alpha (2 records, 1 lineage edges)")).toBeInTheDocument();
expect(screen.getByText("Project Beta (1 records, 0 lineage edges)")).toBeInTheDocument();
expect(screen.getAllByRole("img")).toHaveLength(2);
expect(container.querySelectorAll(".lineage-dag-edge")).toHaveLength(1);
expect(container.querySelector(".lineage-dag-branch")).toBeInTheDocument();
expect(container.querySelector(".lineage-dag-root")).toBeInTheDocument();
expect(container.querySelector(".lineage-dag-node:not(.lineage-dag-root):not(.lineage-dag-branch)")).toBeInTheDocument();
});

it("groups a missing/UUID group id under an Ungrouped heading, sorted last", () => {
const graph: LineageGraph = {
nodes: [
{ id: "u1", group: "3f9c8b1a-1111-2222-3333-444455556666", label: "Loose note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
{ id: "z1", group: "Zeta Corp", label: "Zeta note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
],
edges: [],
};
render(<LineageDag graph={graph} onSelectPost={vi.fn()} />);

const headings = screen.getAllByRole("img").map((img) => img.getAttribute("aria-label"));
expect(headings).toEqual(["Zeta Corp lineage", "Ungrouped lineage"]);
});

it("reports the clicked post id", () => {
const onSelectPost = vi.fn();
const graph: LineageGraph = {
nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }],
edges: [],
};
render(<LineageDag graph={graph} onSelectPost={onSelectPost} />);

fireEvent.click(screen.getByRole("button", { name: "Open post: Kickoff note" }));
expect(onSelectPost).toHaveBeenCalledWith("a1");
});

it("also selects a node via Enter and Space for keyboard users", () => {
const onSelectPost = vi.fn();
const graph: LineageGraph = {
nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }],
edges: [],
};
render(<LineageDag graph={graph} onSelectPost={onSelectPost} />);

const node = screen.getByRole("button", { name: "Open post: Kickoff note" });
fireEvent.keyDown(node, { key: "Enter" });
fireEvent.keyDown(node, { key: " " });
expect(onSelectPost).toHaveBeenCalledTimes(2);
});

it("does not select on an unrelated key press", () => {
const onSelectPost = vi.fn();
const graph: LineageGraph = {
nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }],
edges: [],
};
render(<LineageDag graph={graph} onSelectPost={onSelectPost} />);

fireEvent.keyDown(screen.getByRole("button", { name: "Open post: Kickoff note" }), { key: "Tab" });
expect(onSelectPost).not.toHaveBeenCalled();
});

it("marks the current post distinctly from the rest", () => {
const graph: LineageGraph = {
nodes: [
{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false },
{ id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false },
],
edges: [{ source: "a1", target: "a2", fused_score: 0.5 }],
};
render(<LineageDag graph={graph} onSelectPost={vi.fn()} currentPostId="a2" />);

expect(screen.getByRole("button", { name: "Open post: Follow-up note" })).toHaveAttribute("aria-current", "true");
expect(screen.getByRole("button", { name: "Open post: Kickoff note" })).not.toHaveAttribute("aria-current");
});

it("truncates a very long node label instead of overflowing the graph", () => {
const longLabel = "A".repeat(60);
const graph: LineageGraph = {
nodes: [{ id: "a1", group: "Project Alpha", label: longLabel, occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }],
edges: [],
};
render(<LineageDag graph={graph} onSelectPost={vi.fn()} />);

expect(screen.getByText(`${"A".repeat(33)}…`)).toBeInTheDocument();
expect(screen.queryByText(longLabel)).not.toBeInTheDocument();
// The full label is still reachable via the accessible name for screen readers.
expect(screen.getByRole("button", { name: `Open post: ${longLabel}` })).toBeInTheDocument();
});

it("does not count or render a relationship whose other endpoint is not visible", () => {
const graph: LineageGraph = {
nodes: [
{
id: "visible-note",
group: "Project Alpha",
label: "Visible note",
occurred_at: "2026-01-01T00:00:00Z",
is_root: true,
is_branch_point: false,
},
],
edges: [{ source: "visible-note", target: "hidden-note", fused_score: 0.92 }],
};
const { container } = render(<LineageDag graph={graph} onSelectPost={vi.fn()} />);

expect(screen.getByText("Project Alpha (1 records, 0 lineage edges)")).toBeInTheDocument();
expect(container.querySelector(".lineage-dag-edge")).not.toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion frontend/src/LineageDag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ function truncateLabel(label: string): string {
return label.length > 34 ? `${label.slice(0, 33)}…` : label;
}

/** Render the authorized lineage projection and let the buyer open a post. */
export function LineageDag({
graph,
onSelectPost,
Expand Down Expand Up @@ -43,7 +44,6 @@ export function LineageDag({
{group.edges.map((edge) => {
const from = byId[edge.source];
const to = byId[edge.target];
if (!from || !to) return null;
const midX = (from.x + to.x) / 2;
Comment thread
seonghobae marked this conversation as resolved.
return (
<path
Expand Down
Loading