Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit bb3f037

Browse files
adamleithpclaude
andauthored
feat(chat-thread): user-message minimap in place of the jump pill + copy message next to timestamp (#3829)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c34db79 commit bb3f037

8 files changed

Lines changed: 256 additions & 224 deletions

File tree

packages/ui/src/features/sessions/components/SessionView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ function ComposerWidth({
123123
}) {
124124
return (
125125
<Box
126-
className={compact ? "p-1" : "mx-auto px-2 pb-3"}
126+
className={compact ? "p-1" : "mx-auto pb-3"}
127127
style={compact ? undefined : { maxWidth: CHAT_CONTENT_MAX_WIDTH }}
128128
>
129129
{children}

packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import {
4747
type PromptRecallHandler,
4848
} from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall";
4949
import { MessageJumpPicker } from "@posthog/ui/features/sessions/components/chat-thread/MessageJumpPicker";
50-
import { StickyHeaderOverlay } from "@posthog/ui/features/sessions/components/chat-thread/ThreadStickyHeader";
50+
import { MessageMinimap } from "@posthog/ui/features/sessions/components/chat-thread/MessageMinimap";
5151
import { ToolGroup } from "@posthog/ui/features/sessions/components/chat-thread/ToolGroup";
5252
import { THREAD_HOTKEY_OPTIONS } from "@posthog/ui/features/sessions/components/chat-thread/threadHotkeys";
5353
import {
@@ -79,7 +79,10 @@ import {
7979
import { SessionUpdateView } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView";
8080
import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView";
8181
import { UserMessageAttachments } from "@posthog/ui/features/sessions/components/UserMessageAttachments";
82-
import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants";
82+
import {
83+
CHAT_CONTENT_GUTTER,
84+
CHAT_CONTENT_MAX_WIDTH,
85+
} from "@posthog/ui/features/sessions/constants";
8386
import { DIFFS_HIGHLIGHTER_OPTIONS } from "@posthog/ui/features/sessions/diffHighlighterOptions";
8487
import { useAgentConversationItems } from "@posthog/ui/features/sessions/hooks/useAgentConversationItems";
8588
import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems";
@@ -565,7 +568,7 @@ const ThreadRow = memo(function ThreadRow({
565568
<ChatMessageScrollerItem
566569
messageId={item.id}
567570
scrollAnchor={false}
568-
className="group mx-auto w-full px-4 empty:hidden"
571+
className="group mx-auto w-full empty:hidden"
569572
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
570573
>
571574
<div className="flex flex-col gap-4 empty:hidden">
@@ -676,7 +679,7 @@ function ThreadAutoFollow({ items }: { items: ConversationItem[] }) {
676679
/**
677680
* Keyboard message navigation (Alt/Option+Up/Down) and the Cmd/Ctrl+J jump picker. Rendered inside
678681
* `ChatMessageScrollerProvider` so it can call `scrollToMessage` from the engine — the same primitive
679-
* `StickyHeaderOverlay` uses to jump back to the anchored turn.
682+
* `MessageMinimap` uses to jump back to an earlier turn.
680683
*/
681684
function ThreadKeyboardNav({
682685
items,
@@ -848,13 +851,14 @@ function ThreadScrollBody({
848851
className="group/thread"
849852
onPointerDownCapture={onUserInteract}
850853
>
851-
<StickyHeaderOverlay items={items} />
854+
<MessageMinimap items={items} />
852855
<ThreadAutoFollow items={items} />
853856
<ThreadScrollStateRecorder stateRef={resumeStateRef} />
854857
<ChatMessageScrollerViewport>
855858
<ChatMessageScrollerContent
856859
className="gap-4 py-4 pb-8"
857860
density="default"
861+
style={{ paddingInline: CHAT_CONTENT_GUTTER }}
858862
>
859863
{keyedRows.map(({ item, key }) => (
860864
<ThreadRow
@@ -911,7 +915,7 @@ const FlatRowView = memo(
911915
// collapses entirely (display:none hides the padding too), matching how flex gap
912916
// skips hidden children there.
913917
"mx-auto w-full pb-4 [content-visibility:visible] empty:hidden",
914-
row.inTurn ? "group px-4" : "px-2.5 pt-1",
918+
row.inTurn ? "group" : "px-2.5 pt-1",
915919
)}
916920
style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }}
917921
>
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import {
2+
cn,
3+
DropdownMenu,
4+
DropdownMenuContent,
5+
DropdownMenuItem,
6+
DropdownMenuTrigger,
7+
useChatMessageScroller,
8+
useChatMessageScrollerVisibility,
9+
} from "@posthog/quill";
10+
import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems";
11+
import {
12+
OverflowTickerText,
13+
useOverflowTickerReveal,
14+
} from "@posthog/ui/primitives/OverflowTickerText";
15+
import { useCallback, useMemo, useRef, useState } from "react";
16+
17+
/** Ticks drawn in the collapsed rail. Older turns fall off the top so the rail stays small. */
18+
const MAX_TICKS = 12;
19+
const MAX_LABEL_LENGTH = 200;
20+
/** Message length (chars) at which a tick reaches full width. */
21+
const FULL_WIDTH_CHARS = 220;
22+
const MIN_TICK_WIDTH_PCT = 34;
23+
24+
interface MinimapEntry {
25+
id: string;
26+
label: string;
27+
timestamp: number;
28+
/** 34–100%: longer messages draw longer ticks, so the rail reads like a document minimap. */
29+
widthPct: number;
30+
}
31+
32+
function truncate(text: string, maxLength: number): string {
33+
const singleLine = text.replace(/\n+/g, " ").trim();
34+
if (singleLine.length <= maxLength) return singleLine;
35+
return `${singleLine.slice(0, maxLength)}…`;
36+
}
37+
38+
function formatTime(ts: number): string {
39+
return new Date(ts).toLocaleString([], {
40+
month: "short",
41+
day: "numeric",
42+
hour: "numeric",
43+
minute: "2-digit",
44+
});
45+
}
46+
47+
/**
48+
* One row of the expanded list. The message text is never statically clipped: it fades at the right
49+
* edge while idle and ticker-scrolls to its end on hover or keyboard focus, matching sidebar items.
50+
*/
51+
function MinimapMenuItem({
52+
entry,
53+
isCurrent,
54+
itemRef,
55+
onSelect,
56+
}: {
57+
entry: MinimapEntry;
58+
isCurrent: boolean;
59+
itemRef?: (node: HTMLElement | null) => void;
60+
onSelect: (id: string) => void;
61+
}) {
62+
const { reveal, hoverProps, focusProps } = useOverflowTickerReveal();
63+
64+
return (
65+
<DropdownMenuItem
66+
ref={itemRef}
67+
// The list is a navigation surface, not a one-shot command: clicking scrolls the thread and
68+
// leaves the menu up so the reader can keep hopping between turns.
69+
closeOnClick={false}
70+
onClick={() => onSelect(entry.id)}
71+
{...hoverProps}
72+
{...focusProps}
73+
data-selected={isCurrent || undefined}
74+
className="group/entry h-auto! min-h-7 items-center gap-2 py-1.5 text-left data-selected:bg-fill-selected data-selected:text-gray-12"
75+
>
76+
<OverflowTickerText reveal={reveal} className="flex-1 text-[13px]">
77+
{entry.label}
78+
</OverflowTickerText>
79+
<span className="shrink-0 text-(--gray-10) text-[11px] tabular-nums opacity-0 transition-opacity group-hover/entry:opacity-100 motion-reduce:transition-none">
80+
{formatTime(entry.timestamp)}
81+
</span>
82+
</DropdownMenuItem>
83+
);
84+
}
85+
86+
/**
87+
* Minimap of the user's turns, parked in the thread's top-right corner.
88+
*
89+
* Collapsed it is a small stack of ticks — one per user message, width scaled by message length.
90+
* Hovering or keyboard-focusing the rail opens the full list; picking an entry scrolls that message
91+
* into view and leaves the list open to jump again. Replaces the floating "jump to your message"
92+
* pill, which only ever offered the single anchored turn.
93+
*
94+
* Only this component subscribes to the scroller's per-scroll visibility state, so the message rows
95+
* never re-render as the highlight moves.
96+
*/
97+
export function MessageMinimap({
98+
items,
99+
onJump,
100+
anchorId,
101+
}: {
102+
items: ConversationItem[];
103+
/**
104+
* Jump implementation for the windowed body, whose rows are mostly unmounted — the engine's
105+
* `scrollToMessage` only reaches rows that exist in the DOM. Omitted for the plain body.
106+
*/
107+
onJump?: (id: string) => void;
108+
/**
109+
* Current turn for the windowed body, which tracks its own anchor — the engine's visibility state
110+
* only sees mounted rows. Omitted for the plain body.
111+
*/
112+
anchorId?: string | null;
113+
}) {
114+
const visibility = useChatMessageScrollerVisibility();
115+
const { scrollToMessage } = useChatMessageScroller();
116+
const jump = onJump ?? scrollToMessage;
117+
const currentAnchorId = anchorId ?? visibility.currentAnchorId;
118+
const [open, setOpen] = useState(false);
119+
// Base UI returns focus to the trigger when the menu closes. Without this guard the resulting
120+
// focus event would immediately re-open the menu the user just dismissed or selected from.
121+
const reopenBlockedUntil = useRef(0);
122+
123+
const entries = useMemo<MinimapEntry[]>(() => {
124+
const result: MinimapEntry[] = [];
125+
for (const item of items) {
126+
if (item.type !== "user_message") continue;
127+
const fullText = item.content;
128+
const ratio = Math.min(1, fullText.trim().length / FULL_WIDTH_CHARS);
129+
result.push({
130+
id: item.id,
131+
label: truncate(fullText, MAX_LABEL_LENGTH),
132+
timestamp: item.timestamp,
133+
widthPct: MIN_TICK_WIDTH_PCT + (100 - MIN_TICK_WIDTH_PCT) * ratio,
134+
});
135+
}
136+
return result;
137+
}, [items]);
138+
139+
const handleOpenChange = useCallback((nextOpen: boolean) => {
140+
if (!nextOpen) reopenBlockedUntil.current = Date.now() + 250;
141+
setOpen(nextOpen);
142+
}, []);
143+
144+
// The popup mounts on open, so this ref callback fires exactly when the list appears: bring the
145+
// turn the reader is currently parked on into view instead of opening at the oldest message.
146+
const activeItemRef = useCallback((node: HTMLElement | null) => {
147+
node?.scrollIntoView({ block: "nearest" });
148+
}, []);
149+
150+
// One turn is not a map — there is nowhere to jump to.
151+
if (entries.length < 2) return null;
152+
153+
return (
154+
// Hugs the scroll container's top-right corner (clear of the scrollbar). The thread column
155+
// reserves CHAT_CONTENT_GUTTER on this side, so rows never run underneath the rail.
156+
<div className="pointer-events-none absolute top-2 right-3 z-10">
157+
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
158+
<DropdownMenuTrigger
159+
openOnHover
160+
// Short both ways: the list opens over the rail, so there is no travel to protect
161+
// against, and a lingering close reads as lag when you flick past.
162+
delay={50}
163+
closeDelay={30}
164+
aria-label={`Jump to one of your ${entries.length} messages`}
165+
// Keyboard focus expands too, matching hover. Guarded against the close-then-refocus loop.
166+
onFocus={(event) => {
167+
if (!event.currentTarget.matches(":focus-visible")) return;
168+
if (Date.now() < reopenBlockedUntil.current) return;
169+
setOpen(true);
170+
}}
171+
className={cn(
172+
"pointer-events-auto flex w-[32px] cursor-pointer flex-col items-end gap-[3px]",
173+
"rounded-md bg-(--color-background)/85 p-1.5 backdrop-blur-sm",
174+
"transition-colors duration-150 ease-out motion-reduce:transition-none",
175+
"hover:bg-(--gray-3) data-[popup-open]:bg-(--gray-3)",
176+
"focus-visible:outline-(--accent-8) focus-visible:outline-2 focus-visible:outline-offset-1",
177+
)}
178+
>
179+
{entries.slice(-MAX_TICKS).map((entry) => (
180+
<span
181+
key={entry.id}
182+
aria-hidden="true"
183+
style={{ width: `${entry.widthPct}%` }}
184+
className={cn(
185+
"h-[2px] shrink-0 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
186+
entry.id === currentAnchorId
187+
? "bg-(--accent-9)"
188+
: "bg-(--gray-8)",
189+
)}
190+
/>
191+
))}
192+
</DropdownMenuTrigger>
193+
194+
<DropdownMenuContent
195+
// The list takes the rail's own top-right corner as its origin: pulling back by the
196+
// anchor's height lands the popup's top edge on the rail's top edge, so it expands in
197+
// place (down and to the left) rather than dropping below. It also means the popup opens
198+
// under the pointer, with no dead space to cross that would close a hover menu.
199+
align="end"
200+
side="bottom"
201+
sideOffset={({ anchor }) => -anchor.height}
202+
// Base UI derives the origin from the un-offset anchor edge; pin it to the shared corner
203+
// so the open animation scales out of the rail itself.
204+
className="max-h-[min(60vh,420px)] w-[320px] origin-top-right! overflow-y-auto"
205+
>
206+
{entries.map((entry) => (
207+
<MinimapMenuItem
208+
key={entry.id}
209+
entry={entry}
210+
isCurrent={entry.id === currentAnchorId}
211+
itemRef={entry.id === currentAnchorId ? activeItemRef : undefined}
212+
onSelect={jump}
213+
/>
214+
))}
215+
</DropdownMenuContent>
216+
</DropdownMenu>
217+
</div>
218+
);
219+
}

0 commit comments

Comments
 (0)