feat(desktop): add timeline rail for long chat threads

Adds a compact right-edge prompt timeline for long desktop chat sessions, with hover previews, click-to-jump, active/hover row states, and pane hover-reveal suppression so the rail can live at the hard edge without opening side panels.
This commit is contained in:
Brooklyn Nicholson 2026-06-22 18:33:46 -05:00
parent 9bacd7d4bb
commit 3fffecbdaf
6 changed files with 421 additions and 4 deletions

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { activeTimelineIndex, deriveTimelineEntries, timelinePreview } from './thread-timeline-data'
describe('timelinePreview', () => {
it('collapses whitespace to a single line', () => {
expect(timelinePreview('hello\n\n world\tagain')).toBe('hello world again')
})
it('truncates with an ellipsis past the limit', () => {
const out = timelinePreview('abcdefghij', 5)
expect(out).toBe('abcd…')
expect(out.length).toBe(5)
})
})
describe('deriveTimelineEntries', () => {
it('keeps non-empty user prompts in order', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: 'first' },
{ id: 'a1', role: 'assistant', text: 'answer' },
{ id: 'u2', role: 'user', text: ' second ' }
])
).toEqual([
{ id: 'u1', preview: 'first' },
{ id: 'u2', preview: 'second' }
])
})
it('drops blanks and background-process notifications', () => {
expect(
deriveTimelineEntries([
{ id: 'u1', role: 'user', text: ' ' },
{ id: 'u2', role: 'user', text: '[IMPORTANT: Background process 123 finished]' },
{ id: 'u3', role: 'user', text: 'real prompt' }
]).map(e => e.id)
).toEqual(['u3'])
})
})
describe('activeTimelineIndex', () => {
it('returns the last prompt scrolled to or above the top edge', () => {
expect(activeTimelineIndex([-400, -10, 320])).toBe(1)
})
it('falls back to the first rendered entry', () => {
expect(activeTimelineIndex([null, 120, 480])).toBe(1)
expect(activeTimelineIndex([null, null])).toBe(0)
})
})

View file

@ -0,0 +1,75 @@
// Pure timeline helpers — no React/DOM; tested in thread-timeline-data.test.ts.
export interface TimelineSourceMessage {
id: string
role: string
text: string
}
export interface TimelineEntry {
id: string
preview: string
}
// Injected as user messages for alternation; not human prompts (thread.tsx).
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
const PREVIEW_MAX = 120
export function timelinePreview(text: string, max: number = PREVIEW_MAX): string {
const collapsed = text.replace(/\s+/g, ' ').trim()
if (collapsed.length <= max) {
return collapsed
}
return `${collapsed.slice(0, max - 1).trimEnd()}`
}
export function deriveTimelineEntries(messages: readonly TimelineSourceMessage[]): TimelineEntry[] {
const entries: TimelineEntry[] = []
for (const message of messages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (!text || PROCESS_NOTIFICATION_RE.test(text)) {
continue
}
entries.push({ id: message.id, preview: timelinePreview(text) })
}
return entries
}
/** Last user prompt at/above the viewport top (with slack); else first rendered. */
export function activeTimelineIndex(offsets: readonly (number | null)[], slack: number = 8): number {
let active = -1
let firstRendered = -1
for (let i = 0; i < offsets.length; i++) {
const offset = offsets[i]
if (offset == null) {
continue
}
if (firstRendered === -1) {
firstRendered = i
}
if (offset <= slack) {
active = i
}
}
if (active !== -1) {
return active
}
return firstRendered === -1 ? 0 : firstRendered
}

View file

@ -0,0 +1,272 @@
import { useAuiState } from '@assistant-ui/react'
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { composerPanelCard } from '@/components/chat/composer-dock'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { setPaneHoverRevealSuppressed } from '@/store/panes'
import {
activeTimelineIndex,
deriveTimelineEntries,
type TimelineEntry,
type TimelineSourceMessage
} from './thread-timeline-data'
const MIN_ENTRIES = 4
const VIEWPORT = '[data-slot="aui_thread-viewport"]'
const HOVER_CLOSE_MS = 140
const ROW_CLASS =
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
const POPOVER_SHELL = cn(
'absolute right-full top-1/2 z-50 mr-1.5 max-h-[min(22rem,calc(100vh-8rem))] w-80 max-w-[min(20rem,calc(100vw-2rem))] -translate-y-1/2 overflow-x-hidden overflow-y-auto overscroll-contain p-1 text-popover-foreground transition-[opacity,transform] duration-100 ease-out group-hover/timeline:transition-none',
composerPanelCard,
// Solid fill — composerPanelCard is deliberately translucent; without this,
// directive chips in the transcript bleed through and look like popover overflow.
'bg-(--composer-fill)'
)
function userPromptText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
let out = ''
for (const part of content) {
if (typeof part === 'string') {
out += part
continue
}
if (!part || typeof part !== 'object') {
continue
}
const row = part as { text?: unknown; type?: unknown }
if ((!row.type || row.type === 'text') && typeof row.text === 'string') {
out += row.text
}
}
return out
}
function scrollToPrompt(id: string) {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
const node = viewport?.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
if (!viewport || !node) {
return
}
const top = viewport.scrollTop + (node.getBoundingClientRect().top - viewport.getBoundingClientRect().top) - 8
triggerHaptic('selection')
viewport.scrollTo({ behavior: 'smooth', top: Math.max(0, top) })
}
/** Right-edge prompt rail — hover previews, click to jump. ≥4 user turns only. */
export const ThreadTimeline: FC = () => {
const sourceSignature = useAuiState(s => {
const rows: TimelineSourceMessage[] = []
for (const message of s.thread.messages) {
if (message.role !== 'user') {
continue
}
rows.push({ id: message.id, role: 'user', text: userPromptText(message.content) })
}
return JSON.stringify(rows)
})
const entries = useMemo(
() => deriveTimelineEntries(JSON.parse(sourceSignature) as TimelineSourceMessage[]),
[sourceSignature]
)
const [activeIndex, setActiveIndex] = useState(0)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [open, setOpen] = useState(false)
const closeTimerRef = useRef<number | undefined>(undefined)
const keepOpen = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(true)
setOpen(true)
}, [])
const closeSoon = useCallback(() => {
window.clearTimeout(closeTimerRef.current)
setHoverIndex(null)
setPaneHoverRevealSuppressed(false)
closeTimerRef.current = window.setTimeout(() => setOpen(false), HOVER_CLOSE_MS)
}, [])
useEffect(
() => () => {
window.clearTimeout(closeTimerRef.current)
setPaneHoverRevealSuppressed(false)
},
[]
)
useEffect(() => {
if (entries.length < MIN_ENTRIES) {
setPaneHoverRevealSuppressed(false)
}
}, [entries.length])
useEffect(() => {
const viewport = document.querySelector<HTMLElement>(VIEWPORT)
if (!viewport || entries.length === 0) {
return
}
let raf = 0
const compute = () => {
raf = 0
const top = viewport.getBoundingClientRect().top
const offsets = entries.map(entry => {
const node = viewport.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(entry.id)}"]`)
return node ? node.getBoundingClientRect().top - top : null
})
const next = activeTimelineIndex(offsets)
setActiveIndex(prev => (prev === next ? prev : next))
}
const onScroll = () => {
if (!raf) {
raf = requestAnimationFrame(compute)
}
}
compute()
viewport.addEventListener('scroll', onScroll, { passive: true })
return () => {
viewport.removeEventListener('scroll', onScroll)
if (raf) {
cancelAnimationFrame(raf)
}
}
}, [entries])
if (entries.length < MIN_ENTRIES) {
return null
}
return (
<div
aria-label="Conversation timeline"
className="group/timeline pointer-events-auto absolute right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-end"
data-slot="thread-timeline"
onMouseEnter={keepOpen}
onMouseLeave={closeSoon}
role="navigation"
>
<TimelineTicks
activeIndex={activeIndex}
entries={entries}
onHover={setHoverIndex}
onJump={scrollToPrompt}
/>
<TimelinePopover
activeIndex={activeIndex}
entries={entries}
hoverIndex={hoverIndex}
onHover={setHoverIndex}
onJump={scrollToPrompt}
open={open}
/>
</div>
)
}
const TimelinePopover: FC<{
activeIndex: number
entries: TimelineEntry[]
hoverIndex: number | null
onHover: (index: number) => void
onJump: (id: string) => void
open: boolean
}> = ({ activeIndex, entries, hoverIndex, onHover, onJump, open }) => (
<div
className={cn(
POPOVER_SHELL,
open ? 'pointer-events-auto opacity-100 translate-x-0' : 'pointer-events-none translate-x-1 opacity-0'
)}
data-slot="thread-timeline-popover"
>
{entries.map((entry, index) => {
const hovered = index === hoverIndex
const active = index === activeIndex
return (
<button
aria-label={entry.preview}
className={cn(
ROW_CLASS,
active && 'bg-(--ui-row-active-background) text-foreground',
hovered && 'bg-(--ui-row-hover-background) text-foreground transition-none'
)}
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="block w-full min-w-0 truncate font-medium leading-snug text-foreground">
{entry.preview}
</span>
</button>
)
})}
</div>
)
const TimelineTicks: FC<{
activeIndex: number
entries: TimelineEntry[]
onHover: (index: number) => void
onJump: (id: string) => void
}> = ({ activeIndex, entries, onHover, onJump }) => (
<div className="flex flex-col items-end py-1" data-slot="thread-timeline-ticks">
{entries.map((entry, index) => (
<button
aria-label={entry.preview}
className="group/tick flex h-2 w-7 cursor-pointer items-center justify-end pr-1"
key={entry.id}
onClick={() => onJump(entry.id)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span
className={cn(
'block h-px w-3 transition-opacity duration-100 ease-out',
index === activeIndex
? 'bg-(--theme-primary)'
: 'dither text-(--ui-text-quaternary) opacity-70 group-hover/tick:opacity-100 group-hover/tick:transition-none'
)}
/>
</button>
))}
</div>
)

View file

@ -64,6 +64,7 @@ import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
import { ThreadTimeline } from '@/components/assistant-ui/thread-timeline'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
@ -212,6 +213,7 @@ export const Thread: FC<{
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
<ThreadTimeline />
</div>
)
}
@ -797,7 +799,15 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
function StickyHumanMessageContainer({
attachments,
children,
messageId
}: {
attachments?: ReactNode
children: ReactNode
messageId?: string
}) {
return (
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
@ -806,6 +816,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-message-id={messageId}
data-role="user"
data-slot="aui_user-message-root"
>
@ -990,6 +1001,7 @@ const UserMessage: FC<{
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer
messageId={messageId}
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with

View file

@ -15,7 +15,7 @@ import {
} from 'react'
import { cn } from '@/lib/utils'
import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { $paneHoverRevealSuppressed, $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes'
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
@ -250,6 +250,7 @@ export function Pane({
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const hoverRevealSuppressed = useStore($paneHoverRevealSuppressed)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
@ -378,7 +379,10 @@ export function Pane({
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
className={cn(
'absolute inset-y-0 z-30 [-webkit-app-region:no-drag]',
hoverRevealSuppressed ? 'pointer-events-none' : 'pointer-events-auto'
)}
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
@ -388,7 +392,8 @@ export function Pane({
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
!hoverRevealSuppressed &&
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}

View file

@ -76,6 +76,7 @@ function persist(states: Record<string, PaneStateSnapshot>) {
}
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
export const $paneHoverRevealSuppressed = atom(false)
$paneStates.subscribe(persist)
@ -143,3 +144,4 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
export const setPaneHoverRevealSuppressed = (suppressed: boolean) => $paneHoverRevealSuppressed.set(suppressed)