From 2d410d17b2d1473bb04e90801bcaed76abdc857e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 24 May 2026 19:40:43 -0500 Subject: [PATCH 01/32] =?UTF-8?q?=F0=9F=A4=96=20feat:=20sticky=20table=20o?= =?UTF-8?q?f=20contents=20next=20to=20plans=20in=20chat=20transcript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sticky 'Contents' navigation alongside propose_plan tool cards in the chat transcript when the chat pane has enough horizontal room. Click any entry to jump to that section in the plan; the TOC follows the user while the plan is on screen and scrolls away naturally once the plan exits the viewport. Visibility is CSS-only (a container query on the transcript scrollport plus an opt-in class), so toggling the plan tool expanded/collapsed never produces a layout flash. The transcript scroll container is a named CSS container, so the gate tracks actual chat-pane width and stays correct when sidebars open or close. --- .storybook/preview.tsx | 7 + src/browser/components/ChatPane/ChatPane.tsx | 12 +- .../ProposePlan/PlanTableOfContents.test.tsx | 108 +++++++++++ .../Tools/ProposePlan/PlanTableOfContents.tsx | 100 +++++++++++ .../ProposePlanToolCall.stories.tsx | 99 +++++++++++ .../ProposePlan/extractPlanHeadings.test.ts | 114 ++++++++++++ .../Tools/ProposePlan/extractPlanHeadings.ts | 141 +++++++++++++++ .../Tools/ProposePlanToolCall.test.tsx | 55 ++++++ .../features/Tools/ProposePlanToolCall.tsx | 29 ++- src/browser/styles/globals.css | 168 ++++++++++++++++++ 10 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 src/browser/features/Tools/ProposePlan/PlanTableOfContents.test.tsx create mode 100644 src/browser/features/Tools/ProposePlan/PlanTableOfContents.tsx create mode 100644 src/browser/features/Tools/ProposePlan/extractPlanHeadings.test.ts create mode 100644 src/browser/features/Tools/ProposePlan/extractPlanHeadings.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 60c3553ee3..45cd070c34 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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: { diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index b907b7e74d..2679a4ae17 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1065,11 +1065,19 @@ const ChatPaneContent: React.FC = (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" >
diff --git a/src/browser/features/Tools/ProposePlan/PlanTableOfContents.test.tsx b/src/browser/features/Tools/ProposePlan/PlanTableOfContents.test.tsx new file mode 100644 index 0000000000..49fc4e9256 --- /dev/null +++ b/src/browser/features/Tools/ProposePlan/PlanTableOfContents.test.tsx @@ -0,0 +1,108 @@ +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 + *

/

/

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 }: { entries: PlanHeading[] }) { + const ref = useRef(null); + return ( +
+ +
+

Intro

+

...

+

First section

+

...

+

Second section

+

...

+

Nested

+
+
+ ); +} + +describe("PlanTableOfContents", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("returns null when given fewer than two visible headings", () => { + const view = render(); + expect(view.queryByTestId("plan-toc")).toBeNull(); + }); + + test("ignores deeply nested headings (h5/h6) for display", () => { + const view = render( + + ); + // Both entries are filtered out as too-deep, leaving 0 visible — TOC hidden. + expect(view.queryByTestId("plan-toc")).toBeNull(); + }); + + 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(); + + const headings = Array.from(view.container.querySelectorAll("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 level sits at the leftmost column", () => { + // All entries are h3/h4 — they should render flush-left even though level is 3+. + const view = render( + + ); + + const items = view.container.querySelectorAll(".plan-toc-item"); + expect(items[0].dataset.level).toBe("1"); + expect(items[1].dataset.level).toBe("2"); + }); +}); diff --git a/src/browser/features/Tools/ProposePlan/PlanTableOfContents.tsx b/src/browser/features/Tools/ProposePlan/PlanTableOfContents.tsx new file mode 100644 index 0000000000..90af23ac53 --- /dev/null +++ b/src/browser/features/Tools/ProposePlan/PlanTableOfContents.tsx @@ -0,0 +1,100 @@ +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: + * - The outer `

); - // Layout container that positions the sticky TOC alongside the plan. The - // `plan-toc-layout` class establishes `position: relative` so the TOC's - // absolutely-positioned aside (`left: 100%`) can break out of the centered - // `max-w-4xl` transcript column into the unused right gutter. CSS container - // queries on the transcript scrollport keep it hidden until there's room. + // Layout container that positions the TOC affordances alongside the plan. The + // `plan-toc-layout` class establishes `position: relative` so CSS can place a + // compact hint or sticky TOC in the left gutter when the transcript container + // has enough horizontal room. const planLayout = (
{planUI} diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 386e87c3ae..76889d6436 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1524,49 +1524,70 @@ code { * * `.plan-toc-layout` wraps the plan card so an absolutely-positioned `aside` * (`.plan-toc-aside`) can break out to the LEFT of the centered `max-w-4xl` - * transcript column. The aside is `display: none` by default; a container - * query on the `transcript` scrollport in ChatPane reveals it only when: + * transcript column. The aside has three width states controlled by container + * queries on the `transcript` scrollport in ChatPane: * - * 1. the chat is in centered (not full-width) mode — opted in by the - * `.plan-toc-aware` class on the transcript's inner column, and - * 2. the transcript scrollport is wide enough to hold the centered plan - * plus a TOC gutter on both sides. The plan is centered via `mx-auto`, - * so the TOC's left edge cannot extend past the scrollport without - * being clipped by `overflow-x-hidden` — both gutters must be at least - * `toc width + gap` wide. + * 1. hidden on narrow/mobile widths where even a hint would crowd content, + * 2. a small sideways "Expand to see ToC" hint when there is some gutter but + * not enough for the full nav, and + * 3. the full sticky TOC once the gutter can fit it. * - * Using a container query (rather than viewport `min-width`) is what makes - * the TOC behave correctly when sidebars open/close without the viewport - * changing — and using CSS-only visibility (no JS resize listener) is what - * prevents flashing/tearing when the plan toggles expanded. + * In the full state we shift the whole plan+toc group right by half of the TOC + * column. That keeps the OVERALL component centered in the transcript instead + * of keeping only the plan card centered and leaving the TOC visually heavier on + * the left than the empty right gutter. * --------------------------------------------------------------------------- */ .plan-toc-layout { position: relative; + --plan-toc-compact-column: 1.75rem; + --plan-toc-wide-column: 10rem; + left: 0; } .plan-toc-aside { display: none; /* Anchor the toc's right edge to the plan card's left edge so it extends - leftward into the transcript's left gutter. - Width + padding-right are tuned to keep the visibility threshold reachable - on common desktop widths (1440px+ with mux sidebars) rather than only on - ultra-wide monitors: - max-w-4xl (56rem) + 2 × (width 9rem + gap 1rem) = 76rem */ + leftward into the transcript's left gutter. The compact width only shows a + discoverability hint; the wide container query below expands this to the + full navigation column. */ position: absolute; inset: 0 100% 0 auto; - width: 9rem; - padding-right: 1rem; + width: var(--plan-toc-compact-column); + padding-right: 0.375rem; /* Margin clicks should fall through to the underlying transcript chrome; - only the inner