Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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.

🟠 High components/ChatView.tsx:3268

ChatOutlinePanel is rendered without a key prop, so it does not remount when the user switches threads. Its useEffect that captures the scroll container depends only on [listRef], and since the ref object identity is stable, the effect never re-runs after legendListRef.current is replaced. This leaves the component observing a detached DOM element; the IntersectionObserver, MutationObserver, and click handlers all target the wrong scroll container after a thread switch. Add key={activeThread.id} to force remount on thread change, matching the behavior of MessagesTimeline.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/components/ChatView.tsx around line 3268:

`ChatOutlinePanel` is rendered without a `key` prop, so it does not remount when the user switches threads. Its `useEffect` that captures the scroll container depends only on `[listRef]`, and since the ref object identity is stable, the effect never re-runs after `legendListRef.current` is replaced. This leaves the component observing a detached DOM element; the IntersectionObserver, MutationObserver, and click handlers all target the wrong scroll container after a thread switch. Add `key={activeThread.id}` to force remount on thread change, matching the behavior of `MessagesTimeline`.

Evidence trail:
apps/web/src/components/ChatView.tsx lines 3268-3271 (ChatOutlinePanel without key), line 3243 (MessagesTimeline with key={activeThread.id}), line 701 (legendListRef = useRef). apps/web/src/components/chat/ChatOutlinePanel.tsx lines 41-52 (useEffect with [listRef] dependency), lines 58-78 (IntersectionObserver using scrollContainer as root), lines 88-116 (MutationObserver observing scrollContainer), lines 122-131 (click handler using scrollContainer.querySelector).

timelineEntries={timelineEntries}
scrollContainer={messagesScrollElement}
onScrollToMessage={scrollToMessageRef}
/>
Comment thread
cursor[bot] marked this conversation as resolved.

{/* 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
228 changes: 228 additions & 0 deletions apps/web/src/components/chat/ChatOutlinePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* 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 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;
});
Comment thread
cursor[bot] marked this conversation as resolved.
},
{ 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) => {
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.

oh no

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;
// Only collect user-message IDs (matches IntersectionObserver scope)
if (node.dataset.messageRole === "user" && node.dataset.messageId) {
removedIds.push(node.dataset.messageId);
} else {
node.querySelectorAll('[data-message-role="user"][data-message-id]').forEach((el) => {
const nestedId = (el as HTMLElement).dataset.messageId;
if (nestedId) removedIds.push(nestedId);
});
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (removedIds.length > 0) {
setActiveMessageIds((prev) => {
let changed = false;
const next = new Set(prev);
for (const id of removedIds) {
if (next.delete(id)) changed = true;
}
return changed ? next : prev;
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
});

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);
}, []);
Comment thread
cursor[bot] marked this conversation as resolved.

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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
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