Skip to content
12 changes: 11 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -694,6 +695,7 @@ export default function ChatView(props: ChatViewProps) {
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const scrollToMessageRef = useRef<((messageId: string) => void) | null>(null);
const shouldAutoScrollRef = useRef(true);
const lastKnownScrollTopRef = useRef(0);
const isPointerScrollActiveRef = useRef(false);
Expand Down Expand Up @@ -3330,7 +3332,7 @@ export default function ChatView(props: ChatViewProps) {
{/* Main content area with optional plan sidebar */}
<div className="flex min-h-0 min-w-0 flex-1">
{/* Chat column */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{/* Messages Wrapper */}
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Messages */}
Expand Down Expand Up @@ -3375,9 +3377,17 @@ export default function ChatView(props: ChatViewProps) {
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
workspaceRoot={activeWorkspaceRoot}
onScrollToMessageRef={scrollToMessageRef}
/>
</div>

{/* Chat outline strip — aligned to right edge of message content */}
<ChatOutlinePanel
timelineEntries={timelineEntries}
scrollContainer={messagesScrollElement}
onScrollToMessage={scrollToMessageRef}
/>

{/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
{showScrollToBottom && (
<div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5">
Expand Down
230 changes: 230 additions & 0 deletions apps/web/src/components/chat/ChatOutlinePanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused OutlineEntry interface is dead code

Low Severity

The OutlineEntry interface is defined but never referenced anywhere in the codebase. The outlineEntries array gets its type inferred from the useMemo return value, so this interface serves no purpose and is dead code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c24a29f. Configure here.


interface ChatOutlinePanelProps {
readonly timelineEntries: ReadonlyArray<TimelineEntry>;
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<ReadonlySet<string>>(
() => new Set<string>(),
);

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;
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed-node handler triggers unnecessary re-renders for non-user messages

Low Severity

The MutationObserver removal handler collects IDs from all [data-message-id] elements (including assistant messages), but activeMessageIds only ever contains user message IDs (since the IntersectionObserver only observes [data-message-role="user"] elements). When the virtualizer unmounts assistant message rows, the handler creates a new Set and calls setActiveMessageIds, returning a new reference even though no entries were actually deleted. This causes unnecessary re-renders each time the virtualizer recycles assistant message rows.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c24a29f. Configure here.

});

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<HTMLButtonElement>) => {
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<ReturnType<typeof setTimeout> | 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 (
<div
className="pointer-events-none absolute top-0 z-30"
style={{ left: "calc(50% + 24rem + 0.5rem)" }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="pointer-events-auto relative flex max-h-40 w-5 flex-col items-center gap-[5px] overflow-y-auto py-4">
{outlineEntries.map((entry) => (
<button
key={entry.id}
type="button"
data-outline-id={entry.id}
onClick={handleClick}
className={`h-[3px] w-4 shrink-0 rounded-full transition-colors ${
activeMessageIds.has(entry.id)
? "bg-foreground/60"
: "bg-foreground/25"
} hover:bg-foreground/80`}
/>
))}
</div>

{isHovered ? (
<div
className="pointer-events-auto absolute top-0 right-full mr-2 w-56 rounded-md border border-border bg-popover p-1.5 shadow-lg"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="max-h-72 overflow-y-auto">
{outlineEntries.map((entry) => (
<button
key={entry.id}
type="button"
data-outline-id={entry.id}
onClick={handleClick}
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent/50 ${
activeMessageIds.has(entry.id) ? "bg-accent/30" : ""
}`}
>
<UserIcon className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
<span className="line-clamp-2 text-muted-foreground">
{entry.preview || "(empty)"}
</span>
</button>
))}
</div>
</div>
) : null}
</div>
);
});
40 changes: 40 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ interface MessagesTimelineProps {
end: number;
}>;
}) => void;
onScrollToMessageRef?: React.MutableRefObject<((messageId: string) => void) | null>;
}

export const MessagesTimeline = memo(function MessagesTimeline({
Expand Down Expand Up @@ -132,6 +133,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timestampFormat,
workspaceRoot,
onVirtualizerSnapshot,
onScrollToMessageRef,
}: MessagesTimelineProps) {
const timelineRootRef = useRef<HTMLDivElement | null>(null);
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
Expand Down Expand Up @@ -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<string, number>();
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<number | null>(null);
const onTimelineImageLoad = useCallback(() => {
if (pendingMeasureFrameRef.current !== null) return;
Expand Down
Loading