diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 25ed763952..ee922c291d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -91,6 +91,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 { ChevronDownIcon } from "lucide-react"; @@ -694,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); @@ -3330,7 +3332,7 @@ export default function ChatView(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
{/* Messages Wrapper */}
{/* Messages */} @@ -3375,9 +3377,17 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} + onScrollToMessageRef={scrollToMessageRef} />
+ {/* 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..7d6c3e9841 --- /dev/null +++ b/apps/web/src/components/chat/ChatOutlinePanel.tsx @@ -0,0 +1,230 @@ +/** + * 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"; +import type { TimelineEntry } from "../../session-logic"; + +interface OutlineEntry { + readonly id: string; + readonly preview: string; +} + +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( + () => + 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 — MutationObserver watches for virtualizer + // mount/unmount, IntersectionObserver tracks visibility. + const [activeMessageIds, setActiveMessageIds] = useState>( + () => new Set(), + ); + + useEffect(() => { + if (!scrollContainer) return; + + 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 changed ? next : prev; + }); + }, + { root: scrollContainer, threshold: 0.1 }, + ); + + // Observe any user-message element currently in the DOM + const observeUserMessages = (root: Element) => { + root.querySelectorAll('[data-message-role="user"]').forEach((el) => { + intersectionObserver.observe(el); + }); + }; + + // 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; + // Check the node itself and nested children for message IDs + const id = node.dataset.messageId; + 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) { + 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 via virtualizer (works for all messages, including off-screen) + const handleClick = useCallback( + (e: React.MouseEvent) => { + 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)}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, + [onScrollToMessage, 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 ( +
+
+ {outlineEntries.map((entry) => ( +
+ + {isHovered ? ( +
+
+ {outlineEntries.map((entry) => ( + + ))} +
+
+ ) : null} +
+ ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f08d544cc1..2d7e8dbd8a 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,44 @@ 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) 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" }); + }; + return () => { + onScrollToMessageRef.current = null; + }; + }, [onScrollToMessageRef, messageIndexMap, rowVirtualizer, virtualizedRowCount, scrollContainer]); + const pendingMeasureFrameRef = useRef(null); const onTimelineImageLoad = useCallback(() => { if (pendingMeasureFrameRef.current !== null) return;