Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2d410d1
πŸ€– feat: sticky table of contents next to plans in chat transcript
ammar-agent May 25, 2026
9b70cd4
Use plan title as TOC heading; tighten indents
ammar-agent May 25, 2026
42205bc
Refine plan TOC layout
ammar-agent May 25, 2026
0817d55
Keep plan TOC in gutter
ammar-agent May 25, 2026
3ee1c33
Center plan TOC gutter affordance
ammar-agent May 25, 2026
f54f01b
Align setext extraction with markdown indentation
ammar-agent May 25, 2026
ebfda48
Handle HTML heading edge cases in plan TOC
ammar-agent May 25, 2026
19e6500
Simplify constrained plan TOC hint
ammar-agent May 25, 2026
7286841
Ignore raw HTML blocks in plan TOC extraction
ammar-agent May 25, 2026
a9206c1
Count HTML headings inside raw plan blocks
ammar-agent May 25, 2026
8c96573
Skip script style headings in plan TOC extraction
ammar-agent May 25, 2026
0c74bcf
Count multiline HTML headings in plan TOC
ammar-agent May 25, 2026
fbaf1fe
Count inline raw HTML heading tags
ammar-agent May 25, 2026
3b5a999
Preserve indentation for raw HTML headings
ammar-agent May 25, 2026
794cc55
Skip non-rendered raw HTML heading markup
ammar-agent May 25, 2026
7c94d9f
Match CommonMark type1 HTML block endings
ammar-agent May 25, 2026
a52c7cd
Loosen type1 HTML block boundary parsing
ammar-agent May 25, 2026
c6b4e3d
Preserve list code indentation in plan TOC
ammar-agent May 25, 2026
5dfb6cc
Reject tab-indented HTML headings in plan TOC
ammar-agent May 25, 2026
ea70a27
Allow setext punctuation headings in plan TOC
ammar-agent May 25, 2026
1685df8
Parse multiline setext plan headings
ammar-agent May 25, 2026
0f7664e
Use markdown parser for plan TOC extraction
ammar-agent May 25, 2026
0d97834
Rerun PR checks after resolving review
ammar-agent May 25, 2026
d803b2e
Respect raw HTML allowlist in plan TOC
ammar-agent May 25, 2026
1ab1120
Scan inline raw HTML headings in plan TOC
ammar-agent May 25, 2026
a04e419
Tighten inline TOC heading extraction
ammar-agent May 25, 2026
d94a28f
Preserve nested inline TOC heading text
ammar-agent May 25, 2026
475299d
Polish plan TOC reveal styling
ammar-agent May 25, 2026
f8a2acc
Show plan TOC in wide story
ammar-agent May 25, 2026
0e4f7c0
Pin plan TOC story to wide Chromatic mode
ammar-agent May 25, 2026
ae88d5e
Count pre raw HTML headings in plan TOC
ammar-agent May 25, 2026
c5b4e9e
Handle implicit raw HTML plan headings
ammar-agent May 25, 2026
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
7 changes: 7 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ const preview: Preview = {
styles: { width: "1280px", height: "800px" },
type: "mobile",
},
wide: {
// Wide enough to trigger the @container query that reveals the
// sticky plan TOC next to a centered max-w-4xl transcript.
name: "Desktop wide",
styles: { width: "1600px", height: "900px" },
type: "desktop",
},
},
},
chromatic: {
Expand Down
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Freely make breaking changes, and reorganize / cleanup IPC as needed.
- **Use conditional rendering for testability:** Components like `AgentModePicker` use `{isOpen && <div>...}` instead of Radix Portal. This renders inline and works in happy-dom.
- When adding new dropdown/popover components that need tests/ui coverage, prefer the conditional rendering pattern over Radix Portal.
- E2E tests (tests/e2e) work with Radix but are slow (~2min startup); reserve for scenarios that truly need real Electron.
- **Storybook responsive/Chromatic validation:** Do not prove responsive snapshots by only resizing `iframe.html`; that bypasses Storybook/Chromatic viewport mode configuration. If a story depends on a breakpoint (wide gutters, mobile, touch), pin an explicit `parameters.chromatic.modes[*].viewport`, mirror it with story `globals.viewport` for local viewing, and validate through the Storybook manager or an equivalent Chromatic-mode check. Add a play/static contract when a missing mode would silently snapshot the wrong UI.
- Only use `validateApiKeys()` in tests that actually make AI API calls.

## Tool: todo_write
Expand Down
12 changes: 10 additions & 2 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1065,11 +1065,19 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
// In manual reading mode, anchoring should preserve the user's viewport
// when async highlights/diagrams above the fold settle.
style={autoScroll ? AUTO_SCROLL_TRANSCRIPT_STYLE : undefined}
className="h-full overflow-x-hidden overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
// The named `transcript` container is what the sticky plan TOC queries
// for visibility β€” using a container query rather than a viewport media
// query means sidebars opening/closing correctly hide the TOC even when
// the viewport width is unchanged. See `.plan-toc-aside` in globals.css.
className="@container/transcript h-full overflow-x-hidden overflow-y-auto p-[15px] leading-[1.5] break-words whitespace-pre-wrap"
>
<div
className={cn(
chatTranscriptFullWidth ? "w-full" : "max-w-4xl mx-auto",
// `plan-toc-aware` opts only the centered max-w transcript into the
// sticky plan TOC layout. In `chatTranscriptFullWidth` mode the plan
// already fills the available width, so the TOC would either
// overlap content or get clipped by `overflow-x-hidden`.
chatTranscriptFullWidth ? "w-full" : "plan-toc-aware max-w-4xl mx-auto",
(showTranscriptHydrationPlaceholder || showEmptyTranscriptPlaceholder) && "h-full"
)}
>
Expand Down
163 changes: 163 additions & 0 deletions src/browser/features/Tools/ProposePlan/PlanTableOfContents.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { useRef } from "react";
import { cleanup, fireEvent, render } from "@testing-library/react";

import { installDom } from "../../../../../tests/ui/dom";
import { PlanTableOfContents } from "./PlanTableOfContents";
import type { PlanHeading } from "./extractPlanHeadings";

/**
* Renders the PlanTableOfContents against a hand-rolled DOM that contains real
* <h1>/<h2>/<h3> elements. We test the click β†’ scrollIntoView wiring here
* (instead of inside ProposePlanToolCall.test.tsx) because sibling test files
* mock MarkdownCore at file scope; those mocks make Streamdown render plain text
* without heading tags, which would break index-based DOM lookups.
*/
function HarnessRenderer({ entries, title }: { entries: PlanHeading[]; title?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div>
<PlanTableOfContents entries={entries} contentRef={ref} title={title} />
<div ref={ref} data-testid="plan-body">
<h1>Intro</h1>
<p>...</p>
<h2>First section</h2>
<p>...</p>
<h2>Second section</h2>
<p>...</p>
<h3>Nested</h3>
</div>
</div>
);
}

describe("PlanTableOfContents", () => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
cleanupDom = installDom();
});

afterEach(() => {
cleanup();
cleanupDom?.();
cleanupDom = null;
});

test("returns null when there are fewer than two visible (h2-h4) entries", () => {
// A lone h1 produces zero list entries (h1 is reserved for the heading)
// and would leave a TOC with only a title β€” useless for navigation.
const view = render(<HarnessRenderer entries={[{ renderIndex: 0, level: 1, text: "Only" }]} />);
expect(view.queryByTestId("plan-toc")).toBeNull();
});

test("ignores deeply nested headings (h5/h6) for display", () => {
const view = render(
<HarnessRenderer
entries={[
{ renderIndex: 0, level: 5, text: "Too deep one" },
{ renderIndex: 1, level: 6, text: "Too deep two" },
]}
/>
);
// Both entries are filtered out as too-deep, leaving 0 visible β€” TOC hidden.
expect(view.queryByTestId("plan-toc")).toBeNull();
});

test("filters h1 entries out of the navigation list (they live in the heading)", () => {
const entries: PlanHeading[] = [
{ renderIndex: 0, level: 1, text: "Intro" },
{ renderIndex: 1, level: 2, text: "First section" },
{ renderIndex: 2, level: 2, text: "Second section" },
];

const view = render(<HarnessRenderer entries={entries} title="My Plan" />);

// h2 entries remain as navigable buttons; h1 does not.
expect(view.getByRole("button", { name: "First section" })).toBeDefined();
expect(view.getByRole("button", { name: "Second section" })).toBeDefined();
expect(view.queryByRole("button", { name: "Intro" })).toBeNull();
});

test("renders the supplied title as the TOC heading", () => {
const entries: PlanHeading[] = [
{ renderIndex: 0, level: 2, text: "A" },
{ renderIndex: 1, level: 2, text: "B" },
];

const view = render(<HarnessRenderer entries={entries} title="My Plan Title" />);
const toc = view.getByTestId("plan-toc");
expect(toc.textContent).toContain("My Plan Title");
// Without any h1 in entries, the heading should NOT be a clickable button.
expect(view.queryByRole("button", { name: "My Plan Title" })).toBeNull();
});

test("clicking the TOC heading scrolls to the plan's h1 when one exists", () => {
const entries: PlanHeading[] = [
{ renderIndex: 0, level: 1, text: "Plan Title From Markdown" },
{ renderIndex: 1, level: 2, text: "First section" },
{ renderIndex: 2, level: 2, text: "Second section" },
];

const view = render(<HarnessRenderer entries={entries} title="Plan Title From Markdown" />);

const headings = Array.from(view.container.querySelectorAll<HTMLElement>("h1, h2, h3"));
const scrollCalls: Array<{ target: HTMLElement; options: ScrollIntoViewOptions }> = [];
for (const heading of headings) {
heading.scrollIntoView = ((options: ScrollIntoViewOptions) => {
scrollCalls.push({ target: heading, options });
}) as HTMLElement["scrollIntoView"];
}

fireEvent.click(view.getByRole("button", { name: "Plan Title From Markdown" }));
expect(scrollCalls).toHaveLength(1);
expect(scrollCalls[0].target).toBe(headings[0]); // the h1
});

test("scrolls the correct rendered heading into view when an entry is clicked", () => {
const entries: PlanHeading[] = [
{ renderIndex: 0, level: 1, text: "Intro" },
{ renderIndex: 1, level: 2, text: "First section" },
{ renderIndex: 2, level: 2, text: "Second section" },
{ renderIndex: 3, level: 3, text: "Nested" },
];

const view = render(<HarnessRenderer entries={entries} />);

const headings = Array.from(view.container.querySelectorAll<HTMLElement>("h1, h2, h3"));
expect(headings).toHaveLength(4);

const scrollCalls: Array<{ target: HTMLElement; options: ScrollIntoViewOptions }> = [];
for (const heading of headings) {
heading.scrollIntoView = ((options: ScrollIntoViewOptions) => {
scrollCalls.push({ target: heading, options });
}) as HTMLElement["scrollIntoView"];
}

fireEvent.click(view.getByRole("button", { name: "Second section" }));

expect(scrollCalls).toHaveLength(1);
expect(scrollCalls[0].target).toBe(headings[2]);
// Top alignment ensures the heading lands just below the scrollport edge.
expect(scrollCalls[0].options.block).toBe("start");
});

test("normalizes indentation so the shallowest visible level sits at column 0", () => {
// With h1 reserved for the heading, h2 (the shallowest visible) should
// render at data-level=1, and h3 at data-level=2.
const view = render(
<HarnessRenderer
entries={[
{ renderIndex: 0, level: 1, text: "Title" },
{ renderIndex: 1, level: 2, text: "Section" },
{ renderIndex: 2, level: 3, text: "Subsection" },
]}
/>
);

const items = view.container.querySelectorAll<HTMLElement>(".plan-toc-item");
expect(items).toHaveLength(2);
expect(items[0].dataset.level).toBe("1");
expect(items[1].dataset.level).toBe("2");
});
});
142 changes: 142 additions & 0 deletions src/browser/features/Tools/ProposePlan/PlanTableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import React from "react";
import { cn } from "@/common/lib/utils";
import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip";
import type { PlanHeading } from "./extractPlanHeadings";

/**
* Sticky table of contents rendered alongside a plan in the chat transcript.
*
* Layout:
* - At intermediate widths the left gutter shows only sideways hint text so
* users can discover that widening the transcript reveals the TOC.
* - When the transcript container is wide enough, CSS reveals the same sticky
* left-gutter nav (see `.plan-toc-aside` in globals.css). The sticky container
* follows the user while the plan is on screen, then naturally scrolls away
* once the plan exits the viewport.
*
* Heading navigation is index-based: each entry stores its position among ALL
* h1..h6 elements the plan renders, so clicking a TOC item does
* `container.querySelectorAll("h1,h2,h3,h4,h5,h6")[renderIndex].scrollIntoView()`.
* This avoids touching the shared markdown rehype pipeline just for plan TOC.
*/
export interface PlanTableOfContentsProps {
/** Heading entries extracted from the plan markdown, in document order. */
entries: PlanHeading[];
/**
* Ref to a DOM container whose subtree owns the rendered plan headings.
* Must be a stable ancestor of the markdown output for `scrollIntoView` to
* locate the right element.
*/
contentRef: React.RefObject<HTMLElement | null>;
/**
* Heading rendered at the top of the TOC. Defaults to "Contents".
*
* The plan title is the natural label for a plan TOC, so we let the host
* surface it here β€” that conserves vertical space (no separate "CONTENTS"
* label *and* an h1 list entry) and tightens the visual hierarchy: the
* title sits at column 0, h2 entries align with it, and h3+ are indented
* under their parent h2.
*/
title?: string;
className?: string;
}

const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
const DEFAULT_HEADING = "Contents";

export const PlanTableOfContents: React.FC<PlanTableOfContentsProps> = (props) => {
// h1 is reserved for the TOC's heading (the plan title), so it never appears
// as a list entry β€” but it still consumes a renderIndex because the rendered
// DOM still contains an <h1>. h5/h6 are also hidden as visual noise.
const visibleEntries = props.entries.filter((entry) => entry.level >= 2 && entry.level <= 4);
if (visibleEntries.length < 2) {
// A TOC with 0 or 1 entries adds visual chrome without navigation value.
// (Note: this check intentionally excludes the title, since the title is
// a single label, not a navigable destination on its own.)
return null;
}

// Normalize indentation: anchor the shallowest visible level at column 0 so
// a plan that starts at "###" doesn't look uniformly indented.
const minLevel = Math.min(...visibleEntries.map((entry) => entry.level));

const handleNavigate = (renderIndex: number) => {
const container = props.contentRef.current;
if (!container) return;
const headings = container.querySelectorAll<HTMLElement>(HEADING_SELECTOR);
const target = headings.item(renderIndex);
// `scrollIntoView` is not implemented by happy-dom in unit tests; the guard
// keeps tests from crashing while still exercising the lookup path.
if (target && typeof target.scrollIntoView === "function") {
target.scrollIntoView({ block: "start" });
}
};

// Use the supplied title when non-blank; fall back to "Contents" otherwise.
// Treat " " as blank β€” a whitespace-only title would render as an empty
// heading line and look broken.
const trimmedTitle = props.title?.trim();
const headingText = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : DEFAULT_HEADING;
// If the plan's source markdown begins with an h1, clicking the TOC heading
// jumps to that h1 (the natural "top of plan" target). Otherwise the heading
// is a static label β€” there's nothing meaningful to scroll to.
const titleHeadingEntry = props.entries.find((entry) => entry.level === 1);

return (
<aside
className={cn("plan-toc-aside", props.className)}
aria-label="Plan contents"
// At intermediate widths CSS shows only the sideways hint; at wide widths
// it hides the hint and reveals the sticky nav in the same left gutter.
// The aside itself stays pointer-events:none so margin clicks fall through
// to transcript chrome; only the inner nav becomes interactive.
data-testid="plan-toc"
>
<div className="plan-toc-compact-hint" aria-hidden="true">
Expand to see ToC
</div>
<nav className="plan-toc" data-testid="plan-toc-nav">
{titleHeadingEntry ? (
<TooltipIfPresent tooltip={headingText} side="right" align="start">
<button
type="button"
className="plan-toc-heading plan-toc-heading-link"
onClick={() => handleNavigate(titleHeadingEntry.renderIndex)}
>
{headingText}
</button>
</TooltipIfPresent>
) : (
<div className="plan-toc-heading">{headingText}</div>
)}
<ul className="plan-toc-list">
{visibleEntries.map((entry) => (
<li
key={entry.renderIndex}
className="plan-toc-item"
data-level={entry.level - minLevel + 1}
>
{/*
* Wrap with TooltipIfPresent so users can see the full heading
* in one place even when the side TOC wraps long text across
* multiple lines in the gutter.
*
* `side="right"` keeps the tooltip from drifting off the left
* edge of the transcript β€” the toc lives in the left gutter.
*/}
<TooltipIfPresent tooltip={entry.text} side="right" align="start">
<button
type="button"
className="plan-toc-link"
onClick={() => handleNavigate(entry.renderIndex)}
>
{entry.text}
</button>
</TooltipIfPresent>
</li>
))}
</ul>
</nav>
</aside>
);
};
Loading
Loading