From c412e4cdd2f5693d481cc52a09f95088aa8ec06a Mon Sep 17 00:00:00 2001 From: Abhijitam01 Date: Sat, 11 Apr 2026 08:05:55 +0530 Subject: [PATCH 1/6] feat: add chat outline navigation strip Minimap-style strip beside chat content showing user message bars. Hover to expand popover with previews, click to scroll to message. Active messages highlighted via IntersectionObserver. --- apps/web/src/components/ChatView.tsx | 9 +- .../src/components/chat/ChatOutlinePanel.tsx | 177 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ChatOutlinePanel.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 53bfe2324b..ab72cb448f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -104,6 +104,7 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { ChatOutlinePanel } from "./chat/ChatOutlinePanel"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { @@ -4350,7 +4351,7 @@ export default function ChatView(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
{/* Messages Wrapper */}
{/* Messages */} @@ -4395,6 +4396,12 @@ export default function ChatView(props: ChatViewProps) { />
+ {/* Chat outline strip — aligned to right edge of message content */} + + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
diff --git a/apps/web/src/components/chat/ChatOutlinePanel.tsx b/apps/web/src/components/chat/ChatOutlinePanel.tsx new file mode 100644 index 0000000000..a96a974c2d --- /dev/null +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -0,0 +1,177 @@ +/** + * Minimap-style outline strip beside the chat content. + * Shows a small bar per user message; hover to expand a popover with previews. + * Click any bar or preview to scroll to that message. + */ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { UserIcon } from "lucide-react"; +import type { TimelineEntry } from "../../session-logic"; + +interface OutlineEntry { + readonly id: string; + readonly preview: string; +} + +interface ChatOutlinePanelProps { + readonly timelineEntries: ReadonlyArray; + readonly scrollContainer: HTMLDivElement | null; +} + +// --------------------------------------------------------------------------- +export const ChatOutlinePanel = memo(function ChatOutlinePanel({ + timelineEntries, + scrollContainer, +}: ChatOutlinePanelProps) { + // Derive user-only outline entries + const outlineEntries = useMemo( + () => + timelineEntries + .filter( + (e): e is TimelineEntry & { kind: "message" } => e.kind === "message", + ) + .filter((e) => e.message.role === "user") + .map((e) => ({ + id: e.message.id, + preview: e.message.text.split("\n")[0]?.slice(0, 80) ?? "", + })), + [timelineEntries], + ); + + // Active message tracking via IntersectionObserver + const [activeMessageIds, setActiveMessageIds] = useState>( + () => new Set(), + ); + + useEffect(() => { + if (!scrollContainer) return; + + const visibleIds = new Set(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.messageId; + if (!id) continue; + if (entry.isIntersecting) { + visibleIds.add(id); + } else { + visibleIds.delete(id); + } + } + setActiveMessageIds(new Set(visibleIds)); + }, + { root: scrollContainer, threshold: 0.1 }, + ); + + const elements = scrollContainer.querySelectorAll( + '[data-message-role="user"]', + ); + elements.forEach((el) => observer.observe(el)); + + return () => observer.disconnect(); + }, [scrollContainer, timelineEntries]); + + // Scroll to message on click — single handler using data attributes + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!scrollContainer) return; + const messageId = e.currentTarget.dataset.outlineId; + if (!messageId) return; + const el = scrollContainer.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, + [scrollContainer], + ); + + // Hover state — shows expanded popover + const [isHovered, setIsHovered] = useState(false); + const hoverTimerRef = useRef | null>(null); + + const handleMouseEnter = useCallback(() => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + hoverTimerRef.current = setTimeout(() => { + setIsHovered(false); + }, 200); + }, []); + + useEffect(() => { + return () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + }; + }, []); + + if (outlineEntries.length === 0) { + return null; + } + + return ( + // Positioned beside the max-w-3xl (768px) message content + // 50% + half of 768px + small gap = right beside chat messages +
+ {/* Strip — only wraps the bars */} +
+ {outlineEntries.map((entry) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}); From 1f71035fd63accb720a272169cec27334b663834 Mon Sep 17 00:00:00 2001 From: Abhijitam01 Date: Sat, 11 Apr 2026 16:18:00 +0530 Subject: [PATCH 2/6] fix: improve ChatOutlinePanel observer state management Use functional setState updaters to eliminate shared mutable Set between IntersectionObserver and MutationObserver, preventing race conditions. Batch removal updates into a single state update. Fix theme colors (bg-foreground instead of bg-white) and add max-h-40 scroll cap. --- .../src/components/chat/ChatOutlinePanel.tsx | 97 +++++++++++++------ 1 file changed, 67 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/chat/ChatOutlinePanel.tsx b/apps/web/src/components/chat/ChatOutlinePanel.tsx index a96a974c2d..201a6dc89a 100644 --- a/apps/web/src/components/chat/ChatOutlinePanel.tsx +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -2,6 +2,9 @@ * Minimap-style outline strip beside the chat content. * Shows a small bar per user message; hover to expand a popover with previews. * Click any bar or preview to scroll to that message. + * + * Uses MutationObserver + IntersectionObserver to handle @tanstack/react-virtual + * row mount/unmount — elements are tracked as the virtualizer creates them. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { UserIcon } from "lucide-react"; @@ -17,12 +20,10 @@ interface ChatOutlinePanelProps { readonly scrollContainer: HTMLDivElement | null; } -// --------------------------------------------------------------------------- export const ChatOutlinePanel = memo(function ChatOutlinePanel({ timelineEntries, scrollContainer, }: ChatOutlinePanelProps) { - // Derive user-only outline entries const outlineEntries = useMemo( () => timelineEntries @@ -37,7 +38,8 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ [timelineEntries], ); - // Active message tracking via IntersectionObserver + // Active message tracking — MutationObserver watches for virtualizer + // mount/unmount, IntersectionObserver tracks visibility. const [activeMessageIds, setActiveMessageIds] = useState>( () => new Set(), ); @@ -45,33 +47,72 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ useEffect(() => { if (!scrollContainer) return; - const visibleIds = new Set(); - - const observer = new IntersectionObserver( + const intersectionObserver = new IntersectionObserver( (entries) => { - for (const entry of entries) { - const id = (entry.target as HTMLElement).dataset.messageId; - if (!id) continue; - if (entry.isIntersecting) { - visibleIds.add(id); - } else { - visibleIds.delete(id); + setActiveMessageIds((prev) => { + const next = new Set(prev); + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.messageId; + if (!id) continue; + if (entry.isIntersecting) { + next.add(id); + } else { + next.delete(id); + } } - } - setActiveMessageIds(new Set(visibleIds)); + return next; + }); }, { root: scrollContainer, threshold: 0.1 }, ); - const elements = scrollContainer.querySelectorAll( - '[data-message-role="user"]', - ); - elements.forEach((el) => observer.observe(el)); + // Observe any user-message element currently in the DOM + const observeUserMessages = (root: Element) => { + root.querySelectorAll('[data-message-role="user"]').forEach((el) => { + intersectionObserver.observe(el); + }); + }; - return () => observer.disconnect(); - }, [scrollContainer, timelineEntries]); + // Initial pass for elements already rendered + observeUserMessages(scrollContainer); + + // Watch for virtualizer adding/removing rows + const mutationObserver = new MutationObserver((mutations) => { + const removedIds: string[] = []; + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (!(node instanceof HTMLElement)) continue; + if (node.dataset.messageRole === "user") { + intersectionObserver.observe(node); + } else { + observeUserMessages(node); + } + } + for (const node of mutation.removedNodes) { + if (!(node instanceof HTMLElement)) continue; + const id = node.dataset.messageId; + if (id) removedIds.push(id); + } + } + if (removedIds.length > 0) { + setActiveMessageIds((prev) => { + const next = new Set(prev); + for (const id of removedIds) next.delete(id); + return next; + }); + } + }); + + mutationObserver.observe(scrollContainer, { childList: true, subtree: true }); + + return () => { + intersectionObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [scrollContainer]); - // Scroll to message on click — single handler using data attributes + // Scroll to message — works for elements currently in DOM. + // Off-screen virtualized messages won't have a DOM element yet. const handleClick = useCallback( (e: React.MouseEvent) => { if (!scrollContainer) return; @@ -118,15 +159,12 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ } return ( - // Positioned beside the max-w-3xl (768px) message content - // 50% + half of 768px + small gap = right beside chat messages
- {/* Strip — only wraps the bars */}
@@ -138,13 +176,12 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ onClick={handleClick} className={`h-[3px] w-4 shrink-0 rounded-full transition-colors ${ activeMessageIds.has(entry.id) - ? "bg-white/60" - : "bg-white/25" - } hover:bg-white/80`} + ? "bg-foreground/60" + : "bg-foreground/25" + } hover:bg-foreground/80`} /> ))} - {/* Popover — opens to the left */} {isHovered ? (
Date: Sat, 11 Apr 2026 16:44:11 +0530 Subject: [PATCH 3/6] fix: resolve ChatOutlinePanel bugbot issues - Traverse nested children in removedNodes handler to find data-message-id on wrapper divs (mirrors the addedNodes traversal pattern) - Move popover outside the overflow-y-auto scrollable strip container to prevent CSS clipping --- .../src/components/chat/ChatOutlinePanel.tsx | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/chat/ChatOutlinePanel.tsx b/apps/web/src/components/chat/ChatOutlinePanel.tsx index 201a6dc89a..d8c5e45eca 100644 --- a/apps/web/src/components/chat/ChatOutlinePanel.tsx +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -90,8 +90,16 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ } for (const node of mutation.removedNodes) { if (!(node instanceof HTMLElement)) continue; + // Check the node itself and nested children for message IDs const id = node.dataset.messageId; - if (id) removedIds.push(id); + if (id) { + removedIds.push(id); + } else { + node.querySelectorAll("[data-message-id]").forEach((el) => { + const nestedId = (el as HTMLElement).dataset.messageId; + if (nestedId) removedIds.push(nestedId); + }); + } } } if (removedIds.length > 0) { @@ -162,12 +170,10 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({
-
+
{outlineEntries.map((entry) => (
- {isHovered ? ( -
-
- {outlineEntries.map((entry) => ( - - ))} -
+ {isHovered ? ( +
+
+ {outlineEntries.map((entry) => ( + + ))}
- ) : null} -
+
+ ) : null}
); }); From 410b5226bdcf2c79bc739763fb7a2cc581576eb5 Mon Sep 17 00:00:00 2001 From: Abhijitam01 Date: Sun, 12 Apr 2026 00:02:43 +0530 Subject: [PATCH 4/6] feat: add virtualizer scroll-to-message for ChatOutlinePanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire scrollToIndex through a shared ref so clicking any outline bar scrolls to that message even when it's virtualized off-screen. Pre-compute message-id→row-index Map for O(1) lookups. --- apps/web/src/components/ChatView.tsx | 3 ++ .../src/components/chat/ChatOutlinePanel.tsx | 15 +++++++--- .../src/components/chat/MessagesTimeline.tsx | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0863828c29..ee922c291d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -695,6 +695,7 @@ export default function ChatView(props: ChatViewProps) { ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); + const scrollToMessageRef = useRef<((messageId: string) => void) | null>(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); @@ -3376,6 +3377,7 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} + onScrollToMessageRef={scrollToMessageRef} />
@@ -3383,6 +3385,7 @@ export default function ChatView(props: ChatViewProps) { {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} diff --git a/apps/web/src/components/chat/ChatOutlinePanel.tsx b/apps/web/src/components/chat/ChatOutlinePanel.tsx index d8c5e45eca..e27eacfeba 100644 --- a/apps/web/src/components/chat/ChatOutlinePanel.tsx +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -18,11 +18,13 @@ interface OutlineEntry { interface ChatOutlinePanelProps { readonly timelineEntries: ReadonlyArray; readonly scrollContainer: HTMLDivElement | null; + readonly onScrollToMessage: React.MutableRefObject<((messageId: string) => void) | null>; } export const ChatOutlinePanel = memo(function ChatOutlinePanel({ timelineEntries, scrollContainer, + onScrollToMessage, }: ChatOutlinePanelProps) { const outlineEntries = useMemo( () => @@ -119,13 +121,18 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ }; }, [scrollContainer]); - // Scroll to message — works for elements currently in DOM. - // Off-screen virtualized messages won't have a DOM element yet. + // Scroll to message via virtualizer (works for all messages, including off-screen) const handleClick = useCallback( (e: React.MouseEvent) => { - if (!scrollContainer) return; const messageId = e.currentTarget.dataset.outlineId; if (!messageId) return; + // Use virtualizer scrollToIndex — handles off-screen virtualized rows + if (onScrollToMessage.current) { + onScrollToMessage.current(messageId); + return; + } + // Fallback: querySelector for elements currently in DOM + if (!scrollContainer) return; const el = scrollContainer.querySelector( `[data-message-id="${CSS.escape(messageId)}"]`, ); @@ -133,7 +140,7 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ el.scrollIntoView({ behavior: "smooth", block: "center" }); } }, - [scrollContainer], + [onScrollToMessage, scrollContainer], ); // Hover state — shows expanded popover diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f08d544cc1..094f1519d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -103,6 +103,7 @@ interface MessagesTimelineProps { end: number; }>; }) => void; + onScrollToMessageRef?: React.MutableRefObject<((messageId: string) => void) | null>; } export const MessagesTimeline = memo(function MessagesTimeline({ @@ -132,6 +133,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, onVirtualizerSnapshot, + onScrollToMessageRef, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -259,6 +261,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; }; }, [rowVirtualizer]); + + // Pre-computed message-id → row-index map for O(1) lookups + const messageIndexMap = useMemo(() => { + const map = new Map(); + rows.forEach((row, index) => { + if (row.kind === "message") { + map.set(row.message.id, index); + } + }); + return map; + }, [rows]); + + // Expose scroll-to-message for ChatOutlinePanel + useEffect(() => { + if (!onScrollToMessageRef) return; + onScrollToMessageRef.current = (messageId: string) => { + const index = messageIndexMap.get(messageId); + if (index !== undefined) { + rowVirtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); + } + }; + return () => { + onScrollToMessageRef.current = null; + }; + }, [onScrollToMessageRef, messageIndexMap, rowVirtualizer]); + const pendingMeasureFrameRef = useRef(null); const onTimelineImageLoad = useCallback(() => { if (pendingMeasureFrameRef.current !== null) return; From 69ca494ee11b158111445032466b9eea1284f4d7 Mon Sep 17 00:00:00 2001 From: Abhijitam01 Date: Sun, 12 Apr 2026 00:12:03 +0530 Subject: [PATCH 5/6] fix: handle non-virtualized tail rows in scroll-to-message Messages in the last ~8 rows are rendered directly in the DOM (not tracked by the virtualizer). scrollToIndex with an out-of-bounds index silently fails for these. Fall back to scrollIntoView when the target index >= virtualizedRowCount. --- .../src/components/chat/MessagesTimeline.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 094f1519d9..4b28e5b6f5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -278,14 +278,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (!onScrollToMessageRef) return; onScrollToMessageRef.current = (messageId: string) => { const index = messageIndexMap.get(messageId); - if (index !== undefined) { - rowVirtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); + if (index === undefined) return; + + // Tail rows (index >= virtualizedRowCount) are always in the DOM but not + // tracked by the virtualizer — use scrollIntoView for those. + if (index >= virtualizedRowCount) { + const el = scrollContainer?.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + return; } + + rowVirtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); }; return () => { onScrollToMessageRef.current = null; }; - }, [onScrollToMessageRef, messageIndexMap, rowVirtualizer]); + }, [onScrollToMessageRef, messageIndexMap, rowVirtualizer, virtualizedRowCount, scrollContainer]); const pendingMeasureFrameRef = useRef(null); const onTimelineImageLoad = useCallback(() => { From c24a29ffa6127b81a651b8102f558eab15905234 Mon Sep 17 00:00:00 2001 From: Abhijitam01 Date: Sun, 12 Apr 2026 00:32:21 +0530 Subject: [PATCH 6/6] fix: drop smooth scroll for virtualizer and skip no-op observer updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove behavior: "smooth" from scrollToIndex — conflicts with virtualizer's re-measure correction for distant items, causing silent scroll failures. Matches existing codebase pattern. - Return prev Set when IntersectionObserver fires without actual visibility changes, avoiding unnecessary re-renders during scroll. --- apps/web/src/components/chat/ChatOutlinePanel.tsx | 5 ++++- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatOutlinePanel.tsx b/apps/web/src/components/chat/ChatOutlinePanel.tsx index e27eacfeba..7d6c3e9841 100644 --- a/apps/web/src/components/chat/ChatOutlinePanel.tsx +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -52,17 +52,20 @@ export const ChatOutlinePanel = memo(function ChatOutlinePanel({ const intersectionObserver = new IntersectionObserver( (entries) => { setActiveMessageIds((prev) => { + let changed = false; const next = new Set(prev); for (const entry of entries) { const id = (entry.target as HTMLElement).dataset.messageId; if (!id) continue; + const sizeBefore = next.size; if (entry.isIntersecting) { next.add(id); } else { next.delete(id); } + if (next.size !== sizeBefore) changed = true; } - return next; + return changed ? next : prev; }); }, { root: scrollContainer, threshold: 0.1 }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4b28e5b6f5..2d7e8dbd8a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -292,7 +292,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - rowVirtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" }); + rowVirtualizer.scrollToIndex(index, { align: "center" }); }; return () => { onScrollToMessageRef.current = null;