diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline-data.test.ts b/apps/desktop/src/components/assistant-ui/thread-timeline-data.test.ts new file mode 100644 index 00000000000..a3cc48da56a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-timeline-data.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline-data.ts b/apps/desktop/src/components/assistant-ui/thread-timeline-data.ts new file mode 100644 index 00000000000..e52d1d7c780 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-timeline-data.ts @@ -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 +} diff --git a/apps/desktop/src/components/assistant-ui/thread-timeline.tsx b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx new file mode 100644 index 00000000000..e330cb6d755 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-timeline.tsx @@ -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(VIEWPORT) + const node = viewport?.querySelector(`[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(null) + const [open, setOpen] = useState(false) + const closeTimerRef = useRef(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(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(`[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 ( +
+ + +
+ ) +} + +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 }) => ( +
+ {entries.map((entry, index) => { + const hovered = index === hoverIndex + const active = index === activeIndex + + return ( + + ) + })} +
+) + +const TimelineTicks: FC<{ + activeIndex: number + entries: TimelineEntry[] + onHover: (index: number) => void + onJump: (id: string) => void +}> = ({ activeIndex, entries, onHover, onJump }) => ( +
+ {entries.map((entry, index) => ( + + ))} +
+) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 1ac97c200ca..6057307dec3 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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' && } + ) } @@ -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?: <>
@@ -990,6 +1001,7 @@ const UserMessage: FC<{ return ( (null) // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. @@ -378,7 +379,10 @@ export function Pane({ >