From 224cab6a428b1c31fa0da3776cd3f629bc351748 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 2 Mar 2026 09:51:59 +0200 Subject: [PATCH] feat(ui): add timeline segment selection, xray token histogram, and group logic overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overhauls the message timeline sidebar with segment-level selection, token-aware xray histogram bars, and messageId-based grouping — replacing the previous message-level selection and positional adjacency logic. ## Selection System (SELECTION-SYSTEM) - Dual-level selection: `selectedTimelineIds` (segment IDs) as the source of truth, bridged to `selectedForDeletion` (message IDs) via a reactive `createEffect`. - CTRL+Click: toggles individual segments. Clicking an assistant parent with unexpanded tools expands the group and selects all members. Re-clicking collapses and deselects. - SHIFT+Click: range selection. Direction follows anchor state — if the anchor is selected the range is additive; if not, subtractive. - Escape: clears all selection via a global keydown listener. - Long-press (500ms, 10px jitter tolerance): mobile/touch selection via pointer events with context-menu suppression. - Scroll anchor preservation: captures badge offsetTop before toggling visibility, restores scrollTop after layout shift. ## Token Count Fix (TOKEN-COUNT-FIX) - New `getPartCharCount()` estimates characters for any `ClientPart`. Handles text, tool state (input/output/metadata), and content arrays. - **Skips `filediff` metadata key** — this key contains full before/after file content that inflated character counts by 10-100x. - `totalChars` field added to `TimelineSegment` and `PendingSegment`, accumulated during `buildTimelineSegments()`. ## Scroll Performance (SCROLL-PERF) - Two-tier positioning replaces per-badge `getBoundingClientRect` on every scroll event: 1. `computeBadgeLayout()` — expensive pass, runs once on activation, resize, or expansion. Stores `layoutTop` relative to scroll content. 2. `handleScrollRaf()` — RAF-throttled, reads 1 container rect per frame. Derives all badge screen positions arithmetically. - `clipBounds` subtracts delete toolbar height + 16px gap when toolbar is visible, preventing xray bars from overlapping the toolbar. ## Group Logic (GROUP-LOGIC) - `getAdjacentGroup()`: changed from backward positional walk to `segments.filter(s => s.messageId === clicked.messageId)`. Fixes cross-message group overlap when consecutive tool segments belong to different assistant messages. - `groupRole()`: checks for sibling tools via `messageId`. - `isGroupStart()`: checks previous segment's `messageId`. - Only assistant badges trigger group selection; tool and user badges are always standalone. ## Active Highlight (ACTIVE-HIGHLIGHT) - Renamed `activeMessageId` → `activeSegmentId` (signal, prop, and comparison). Clicking a badge now highlights only that specific badge, not all badges sharing the same messageId. - Intersection observer resolves messageId → first segment's id. - Auto-scroll effect uses segment id directly (no `.find()` lookup). ## XRay Histogram Bars (XRAY-BARS) - Portal-based overlay with two bars per segment: - Relative bar: width = tokens/maxTokens, green-to-red gradient. - Absolute bar: width = tokens/10000 (capped), grey, with red glow overflow indicator when tokens exceed ABSOLUTE_TOKEN_CAP (10K). - Token labels as pill-shaped badges (white bg, dark border, 12px font, 1.5rem height matching badge height) at the left tip of each bar. - `liveSegmentChars` memo fetches fresh char counts from the message store to handle stale tool output that arrived after segment creation. - `aggregateTokensByMessageId` memo: O(n) pre-computation replacing the previous O(n²) per-segment iteration inside ``. - `clip-path: inset(...)` clips bars at layout edges. ## Delete Toolbar Token Display (TOKEN-TOTAL-IN-TOOLBAR) - Removed `outputTokensByMessageId` (backend `entry.outputTokens` only counted assistant output, missing tool result content entirely). - `selectedTokenTotal` now sums `seg.totalChars` across all segments for each selected messageId, divides by 4. Consistent with xray bars. - Three color-coded pills: Before (muted, current context), Selection (red, tokens being removed), After (green, remaining after deletion). Eliminates mental arithmetic for users targeting a context token count. ## Delete Hover Fix - Removed `selected.has(segment.messageId)` → `return true` from `isDeleteHovered()`. The red delete overlay now only activates from actual hover interactions (kind === "message" or "deleteUpTo"), not from the selection state. This prevents the red overlay from masking the blue segment-level selection highlight. ## CSS Changes - message-selection.css: Restyled toolbar with accent-primary scheme, three-pill token group, button variants (--delete, --cancel), hint. - message-timeline.css: Selection styling (!important overrides), group indicators (left border), xray overlay (fixed fullscreen, z-index 40), rib/bar/label styles, container layout, stacking context isolation. ## Files Changed - packages/ui/src/components/message-section.tsx (+345/-197) - packages/ui/src/components/message-timeline.tsx (+671/-199) - packages/ui/src/lib/i18n/messages/en/messaging.ts (+1/-2) - packages/ui/src/styles/messaging/message-selection.css (+107/-34) - packages/ui/src/styles/messaging/message-timeline.css (+146/-0) Co-Authored-By: Claude Opus 4.6 --- .../ui/src/components/message-section.tsx | 345 +++++++-- .../ui/src/components/message-timeline.tsx | 661 +++++++++++++++--- .../ui/src/lib/i18n/messages/en/messaging.ts | 3 +- .../styles/messaging/message-selection.css | 107 ++- .../src/styles/messaging/message-timeline.css | 146 ++++ 5 files changed, 1070 insertions(+), 192 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 8ebff4f1..300d9872 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -14,7 +14,6 @@ import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" - const SCROLL_SCOPE = "session" const SCROLL_SENTINEL_MARGIN_PX = 48 const USER_SCROLL_INTENT_WINDOW_MS = 600 @@ -79,11 +78,177 @@ export default function MessageSection(props: MessageSectionProps) { }) const handleTimelineSegmentClick = (segment: TimelineSegment) => { + setLastSelectionAnchorId(segment.id) + setActiveSegmentId(segment.id) if (typeof document === "undefined") return const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) } - + + const [selectedTimelineIds, setSelectedTimelineIds] = createSignal>(new Set()) + const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal(null) + const [expandedMessageIds, setExpandedMessageIds] = createSignal>(new Set()) + + // Build the message group for a segment. + // Tool calls belong to the same message as their assistant. Only the + // assistant badge triggers group selection; user badges are standalone. + const getAdjacentGroup = (_clickedIndex: number, segments: TimelineSegment[]): TimelineSegment[] => { + const clicked = segments[_clickedIndex] + if (clicked.type === "assistant") { + // Group = all segments from the same message (assistant + its tools). + // Uses messageId instead of positional adjacency to avoid cross-message + // overlap when tool-only messages produce no assistant segment separator. + return segments.filter((s) => s.messageId === clicked.messageId) + } + // User, tool, and compaction segments are standalone. + return [clicked] + } + + const handleToggleTimelineSelection = (id: string) => { + setLastSelectionAnchorId(id) + const segments = timelineSegments() + const segmentIndex = segments.findIndex((s) => s.id === id) + if (segmentIndex === -1) return + const segment = segments[segmentIndex] + + const isCurrentlySelected = selectedTimelineIds().has(id) + const group = getAdjacentGroup(segmentIndex, segments) + const hasToolsInGroup = group.some((s) => s.type === "tool") + const toolMsgIds = new Set(group.filter((s) => s.type === "tool").map((s) => s.messageId)) + const isGroupExpanded = toolMsgIds.size > 0 && [...toolMsgIds].every((mid) => expandedMessageIds().has(mid)) + + if (!isCurrentlySelected && (segment.type === "assistant" || segment.type === "user") && hasToolsInGroup && !isGroupExpanded) { + // First click on a parent with sibling tools: expand + select entire group + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + for (const s of group) next.add(s.id) + return next + }) + setExpandedMessageIds((prev) => { + const next = new Set(prev) + for (const s of group) { + if (s.type === "tool") next.add(s.messageId) + } + return next + }) + } else if (isCurrentlySelected) { + if ((segment.type === "assistant" || segment.type === "user") && isGroupExpanded) { + // Parent re-click: collapse + deselect entire group + const newSelected = new Set(selectedTimelineIds()) + for (const s of group) newSelected.delete(s.id) + setSelectedTimelineIds(newSelected) + setExpandedMessageIds((prev) => { + const next = new Set(prev) + for (const s of group) { + if (s.type === "tool") next.delete(s.messageId) + } + return next + }) + } else if (segment.type === "tool") { + // Individual tool deselect + const newSelected = new Set(selectedTimelineIds()) + newSelected.delete(id) + setSelectedTimelineIds(newSelected) + // Collapse tool's messageId if no other selected segment needs it + const anyOtherSelected = group.some((s) => s.type === "tool" && s.id !== id && newSelected.has(s.id)) + if (!anyOtherSelected) { + setExpandedMessageIds((prev) => { + const next = new Set(prev) + for (const s of group) { + if (s.type === "tool") next.delete(s.messageId) + } + return next + }) + } + } else { + // Deselect just this non-tool segment + const newSelected = new Set(selectedTimelineIds()) + newSelected.delete(id) + setSelectedTimelineIds(newSelected) + } + } else { + // Select just this segment (tool badge or already-expanded parent) + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + next.add(id) + return next + }) + } + } + + const handleSelectRangeTimeline = (id: string) => { + const anchorId = lastSelectionAnchorId() + if (!anchorId) { + handleToggleTimelineSelection(id) + return + } + + const segments = timelineSegments() + const anchorIndex = segments.findIndex((s) => s.id === anchorId) + const targetIndex = segments.findIndex((s) => s.id === id) + + if (anchorIndex === -1 || targetIndex === -1) { + handleToggleTimelineSelection(id) + return + } + + const start = Math.min(anchorIndex, targetIndex) + const end = Math.max(anchorIndex, targetIndex) + + // Range action follows the anchor state: + // - If the anchor is selected → add the range (extend selection) + // - If the anchor is NOT selected → remove the range (extend deselection) + const anchorSelected = selectedTimelineIds().has(anchorId) + + if (anchorSelected) { + // Additive: select everything in range + const messagesToExpand = new Set() + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) { + next.add(segments[i].id) + if (segments[i].type === "tool") messagesToExpand.add(segments[i].messageId) + } + return next + }) + if (messagesToExpand.size > 0) { + setExpandedMessageIds((prev) => { + const next = new Set(prev) + for (const msgId of messagesToExpand) next.add(msgId) + return next + }) + } + } else { + // Subtractive: deselect everything in range + const messagesToCollapse = new Set() + const newSelected = new Set(selectedTimelineIds()) + for (let i = start; i <= end; i++) { + newSelected.delete(segments[i].id) + if (segments[i].type === "tool") messagesToCollapse.add(segments[i].messageId) + } + setSelectedTimelineIds(newSelected) + if (messagesToCollapse.size > 0) { + setExpandedMessageIds((prev) => { + const next = new Set(prev) + for (const msgId of messagesToCollapse) { + // Only collapse if no other selected segment still needs this message expanded + const stillNeeded = segments.some((s) => + s.messageId === msgId && s.type === "tool" && newSelected.has(s.id) + ) + if (!stillNeeded) next.delete(msgId) + } + return next + }) + } + } + } + + const handleClearTimelineSelection = () => { + setSelectedTimelineIds(new Set()) + setLastSelectionAnchorId(null) + setExpandedMessageIds(new Set()) + } + const lastAssistantIndex = createMemo(() => { const ids = messageIds() const resolvedStore = store() @@ -149,7 +314,7 @@ export default function MessageSection(props: MessageSectionProps) { setTimelineSegments((prev) => [...prev, ...newSegments]) } } - const [activeMessageId, setActiveMessageId] = createSignal(null) + const [activeSegmentId, setActiveSegmentId] = createSignal(null) const [deleteHover, setDeleteHover] = createSignal({ kind: "none" }) @@ -157,6 +322,27 @@ export default function MessageSection(props: MessageSectionProps) { const isDeleteMode = createMemo(() => selectedForDeletion().size > 0) const selectedDeleteCount = createMemo(() => selectedForDeletion().size) + const selectedTokenTotal = createMemo(() => { + const selected = selectedForDeletion() + if (selected.size === 0) return 0 + const segments = timelineSegments() + let total = 0 + for (const messageId of selected) { + let charTotal = 0 + for (const seg of segments) { + if (seg.messageId === messageId) charTotal += seg.totalChars + } + total += Math.max(Math.round(charTotal / 4), 1) + } + return total + }) + + const formatTokenCount = (tokens: number): string => { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` + return String(tokens) + } + const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId) const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => { @@ -175,10 +361,37 @@ export default function MessageSection(props: MessageSectionProps) { const clearDeleteMode = () => { setSelectedForDeletion(new Set()) setDeleteHover({ kind: "none" }) + setSelectedTimelineIds(new Set()) + setLastSelectionAnchorId(null) + setExpandedMessageIds(new Set()) } + createEffect(() => { + const timelineIds = selectedTimelineIds() + if (timelineIds.size === 0) { + setSelectedForDeletion(new Set()) + return + } + const segments = timelineSegments() + const affectedMessageIds = new Set() + for (const segId of timelineIds) { + const segment = segments.find((s) => s.id === segId) + if (segment) affectedMessageIds.add(segment.messageId) + } + setSelectedForDeletion(affectedMessageIds) + }) + const selectAllForDeletion = () => { - setSelectedForDeletion(new Set(messageIds())) + const allMessageIds = messageIds() + setSelectedForDeletion(new Set(allMessageIds)) + // Also select all timeline segments and expand tool groups + const segments = timelineSegments() + setSelectedTimelineIds(new Set(segments.map((s) => s.id))) + const toolMessageIds = new Set() + for (const seg of segments) { + if (seg.type === "tool") toolMessageIds.add(seg.messageId) + } + setExpandedMessageIds(toolMessageIds) } const deleteSelectedMessages = async () => { @@ -565,6 +778,7 @@ export default function MessageSection(props: MessageSectionProps) { const ids = messageIds() if (loading) { + handleClearTimelineSelection() previousTimelineIds = [] setTimelineSegments([]) seenTimelineMessageIds.clear() @@ -769,6 +983,17 @@ export default function MessageSection(props: MessageSectionProps) { } }) + createEffect(() => { + if (typeof document === "undefined") return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && (selectedTimelineIds().size > 0 || selectedForDeletion().size > 0)) { + clearDeleteMode() + } + } + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + createEffect(() => { const target = containerRef const loading = props.loading @@ -873,7 +1098,10 @@ export default function MessageSection(props: MessageSectionProps) { if (best) { const anchorId = (best.target as HTMLElement).id const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId - setActiveMessageId((current) => (current === messageId ? current : messageId)) + const firstSeg = timelineSegments().find((s) => s.messageId === messageId) + if (firstSeg) { + setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id)) + } } }, { root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 }, @@ -1017,14 +1245,75 @@ export default function MessageSection(props: MessageSectionProps) { )} + + + + - +
- - - - diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 61d42793..e7af2ef8 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -1,4 +1,5 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" +import { Portal } from "solid-js/web" import MessagePreview from "./message-preview" import { messageStoreBus } from "../stores/message-v2/bus" import type { ClientPart } from "../types/message" @@ -22,12 +23,18 @@ export interface TimelineSegment { toolPartIds?: string[] partIds?: string[] partId?: string + totalChars: number } interface MessageTimelineProps { segments: TimelineSegment[] onSegmentClick?: (segment: TimelineSegment) => void - activeMessageId?: string | null + onToggleSelection?: (id: string) => void + onSelectRange?: (id: string) => void + onClearSelection?: () => void + selectedIds?: Accessor> + expandedMessageIds?: Accessor> + activeSegmentId?: string | null instanceId: string sessionId: string showToolSegments?: boolean @@ -39,6 +46,9 @@ interface MessageTimelineProps { } const MAX_TOOLTIP_LENGTH = 220 +const LONG_PRESS_MS = 500 +const JITTER_THRESHOLD = 10 +const ABSOLUTE_TOKEN_CAP = 10000 type ToolCallPart = Extract @@ -47,6 +57,7 @@ interface PendingSegment { texts: string[] reasoningTexts: string[] partIds: string[] + totalChars: number hasPrimaryText: boolean } @@ -57,6 +68,67 @@ function truncateText(value: string): string { return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…` } +function getPartCharCount(part: ClientPart): number { + if (!part) return 0 + let count = 0 + + if (typeof (part as any).text === "string") { + count += (part as any).text.length + } + + if (part.type === "tool") { + const state = (part as any).state + if (state) { + if (state.input) { + try { + count += JSON.stringify(state.input).length + } catch {} + } + if (state.output) { + if (typeof state.output === "string") { + count += state.output.length + } else { + try { + count += JSON.stringify(state.output).length + } catch {} + } + } + if (state.metadata) { + for (const [key, val] of Object.entries(state.metadata)) { + // Skip filediff — it contains full before/after file content and + // would inflate the character count by 10-100x for large files. + if (key === "filediff") continue + if (typeof val === "string") { + count += val.length + } else if (val && typeof val === "object") { + try { + count += JSON.stringify(val).length + } catch {} + } + } + } + } + } + + if (Array.isArray((part as any).content)) { + count += (part as any).content.reduce((acc: number, entry: unknown) => { + if (typeof entry === "string") return acc + entry.length + if (entry && typeof entry === "object") { + let entryCount = (String((entry as any).text || "")).length + (String((entry as any).value || "")).length + if (Array.isArray((entry as any).content)) { + entryCount += (entry as any).content.reduce((innerAcc: number, sub: unknown) => { + if (typeof sub === "string") return innerAcc + sub.length + return innerAcc + (String((sub as any)?.text || "")).length + }, 0) + } + return acc + entryCount + } + return acc + }, 0) + } + return count +} + function collectReasoningText(part: ClientPart): string { const stringifySegment = (segment: unknown): string => { if (typeof segment === "string") { @@ -182,7 +254,7 @@ export function buildTimelineSegments( [...pending.texts, ...pending.reasoningTexts], pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"), ) - + result.push({ id: `${record.id}:${segmentIndex}`, messageId: record.id, @@ -191,11 +263,12 @@ export function buildTimelineSegments( tooltip, shortLabel, partIds: pending.partIds, + totalChars: pending.totalChars, }) segmentIndex += 1 pending = null } - + const ensureSegment = (type: TimelineSegmentType): PendingSegment => { if (!pending || pending.type !== type) { flushPending() @@ -204,6 +277,7 @@ export function buildTimelineSegments( texts: [], reasoningTexts: [], partIds: [], + totalChars: 0, hasPrimaryText: type !== "assistant", } } @@ -229,6 +303,7 @@ export function buildTimelineSegments( tooltip: formatToolTooltip([title], t), shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"), toolPartIds: partId ? [partId] : undefined, + totalChars: getPartCharCount(part), }) segmentIndex += 1 continue @@ -243,10 +318,11 @@ export function buildTimelineSegments( if (typeof (part as any).id === "string" && (part as any).id.length > 0) { target.partIds.push((part as any).id) } + target.totalChars += getPartCharCount(part) } continue } - + if (part.type === "compaction") { flushPending() const isAuto = Boolean((part as any)?.auto) @@ -259,6 +335,7 @@ export function buildTimelineSegments( tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), variant: isAuto ? "auto" : "manual", partId, + totalChars: 0, }) segmentIndex += 1 continue @@ -267,7 +344,7 @@ export function buildTimelineSegments( if (part.type === "step-start" || part.type === "step-finish") { continue } - + const text = collectTextFromPart(part, t) if (text.trim().length === 0) continue const target = ensureSegment(defaultContentType) @@ -277,12 +354,13 @@ export function buildTimelineSegments( if (typeof (part as any).id === "string" && (part as any).id.length > 0) { target.partIds.push((part as any).id) } + target.totalChars += getPartCharCount(part) } } flushPending() - + return result } @@ -299,7 +377,7 @@ const MessageTimeline: Component = (props) => { let closeTimer: number | null = null const showTools = () => props.showToolSegments ?? true const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const } - + const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { if (element) { buttonRefs.set(segmentId, element) @@ -307,7 +385,7 @@ const MessageTimeline: Component = (props) => { buttonRefs.delete(segmentId) } } - + const clearHoverTimer = () => { if (hoverTimer !== null && typeof window !== "undefined") { window.clearTimeout(hoverTimer) @@ -333,8 +411,11 @@ const MessageTimeline: Component = (props) => { setHoverAnchorRect(null) }, 160) } - + const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => { + // Suppress previews when items are selected or during long-press + if ((props.selectedIds?.().size ?? 0) > 0 || longPressTimer !== null) return + if (typeof window === "undefined") return clearHoverTimer() clearCloseTimer() @@ -349,7 +430,7 @@ const MessageTimeline: Component = (props) => { const handleMouseLeave = () => { scheduleClose() } - + createEffect(() => { if (typeof window === "undefined") return const anchor = hoverAnchorRect() @@ -371,11 +452,258 @@ const MessageTimeline: Component = (props) => { clearCloseTimer() }) - createEffect(on(() => props.activeMessageId, (activeId) => { + // --- Selection & histogram rib state --- + const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0) + + // Stable layout offsets per badge (relative to scroll content), recomputed only + // on activation, resize, or expansion — NOT on every scroll frame. + const [badgeOffsets, setBadgeOffsets] = createSignal>({}) + // Lightweight scroll state: 1 getBoundingClientRect on container per frame. + const [containerScroll, setContainerScroll] = createSignal({ containerTop: 0, scrollTop: 0, left: 0 }) + const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) + const [clipBounds, setClipBounds] = createSignal<{ top: number; bottom: number }>({ top: 0, bottom: typeof window !== "undefined" ? window.innerHeight : 800 }) + let scrollContainerRef: HTMLDivElement | undefined + let scrollRafId: number | null = null + + // Full layout recomputation: reads every badge's getBoundingClientRect once, + // then stores offsets relative to the scroll content so they survive scrolling. + const computeBadgeLayout = () => { + if (!isSelectionActive() || !scrollContainerRef) return + const containerRect = scrollContainerRef.getBoundingClientRect() + const scrollTop = scrollContainerRef.scrollTop + const offsets: Record = {} + + for (const [id, element] of buttonRefs.entries()) { + if (!element) continue + const rect = element.getBoundingClientRect() + // Store position relative to scroll content (survives scrolling). + offsets[id] = { + layoutTop: rect.top - containerRect.top + scrollTop, + height: rect.height, + } + } + setBadgeOffsets(offsets) + setContainerScroll({ containerTop: containerRect.top, scrollTop, left: containerRect.left }) + + if (typeof window !== "undefined") { + setWindowWidth(window.innerWidth) + const layout = scrollContainerRef.closest(".message-layout") + if (layout) { + const layoutRect = layout.getBoundingClientRect() + // Shrink clip bottom when the delete toolbar is visible so bars + // disappear behind it instead of overlapping. + const toolbar = layout.querySelector(".message-delete-mode-toolbar") + const toolbarInset = toolbar ? toolbar.getBoundingClientRect().height + 16 : 0 + setClipBounds({ top: layoutRect.top, bottom: layoutRect.bottom - toolbarInset }) + } + } + } + + // RAF-throttled scroll handler: only 1 container getBoundingClientRect per frame + // instead of N badge getBoundingClientRect calls. + const handleScrollRaf = () => { + if (!isSelectionActive()) return + if (scrollRafId !== null) return + scrollRafId = requestAnimationFrame(() => { + scrollRafId = null + if (!scrollContainerRef) return + const containerRect = scrollContainerRef.getBoundingClientRect() + setContainerScroll({ + containerTop: containerRect.top, + scrollTop: scrollContainerRef.scrollTop, + left: containerRect.left, + }) + }) + } + + createEffect(() => { + if (isSelectionActive()) { + computeBadgeLayout() + // Deferred pass: tool segments become visible when selection activates, + // but they may need a layout pass before getBoundingClientRect is accurate. + requestAnimationFrame(computeBadgeLayout) + window.addEventListener("resize", computeBadgeLayout) + onCleanup(() => { + window.removeEventListener("resize", computeBadgeLayout) + if (scrollRafId !== null) { + cancelAnimationFrame(scrollRafId) + scrollRafId = null + } + }) + } + }) + + // Re-compute badge layout after expansion changes (tools become visible in DOM) + createEffect(() => { + props.expandedMessageIds?.() + if (isSelectionActive()) { + requestAnimationFrame(computeBadgeLayout) + } + }) + + const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) + + // Compute fresh char counts from the store. segment.totalChars can be stale for + // tool parts whose output arrived after the timeline segment was first built. + const liveSegmentChars = createMemo(() => { + if (!isSelectionActive()) return {} as Record + const result: Record = {} + const processedMessages = new Set() + const resolvedStore = store() + + for (const segment of props.segments) { + if (processedMessages.has(segment.messageId)) continue + processedMessages.add(segment.messageId) + + const record = resolvedStore.getMessage(segment.messageId) + if (!record) { + for (const s of props.segments) { + if (s.messageId === segment.messageId) result[s.id] = s.totalChars + } + continue + } + + const { orderedParts } = buildRecordDisplayData(props.instanceId, record) + if (!orderedParts?.length) { + for (const s of props.segments) { + if (s.messageId === segment.messageId) result[s.id] = s.totalChars + } + continue + } + + // Map partId → fresh char count + const partChars = new Map() + for (const part of orderedParts) { + const pid = typeof (part as any)?.id === "string" ? (part as any).id : null + if (pid) partChars.set(pid, getPartCharCount(part)) + } + + // Assign fresh chars to each segment of this message + for (const s of props.segments) { + if (s.messageId !== segment.messageId) continue + const ids = [...(s.partIds ?? []), ...(s.toolPartIds ?? [])] + let chars = 0 + for (const pid of ids) chars += partChars.get(pid) ?? 0 + result[s.id] = chars > 0 ? chars : s.totalChars + } + } + return result + }) + + // Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup. + // Avoids the previous O(n²) pattern of iterating all segments inside each item. + const aggregateTokensByMessageId = createMemo(() => { + const chars = liveSegmentChars() + const result: Record = {} + for (const s of props.segments) { + result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars) + } + for (const id of Object.keys(result)) { + result[id] = Math.max(Math.round(result[id] / 4), 1) + } + return result + }) + + const getSegmentTokens = (segment: TimelineSegment): number => { + const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false + if (!isExpanded && (segment.type === "assistant" || segment.type === "user")) { + return aggregateTokensByMessageId()[segment.messageId] ?? 1 + } + const chars = liveSegmentChars()[segment.id] ?? segment.totalChars + return Math.max(Math.round(chars / 4), 1) + } + + const getMessageAggregateTokens = (messageId: string): number => { + return aggregateTokensByMessageId()[messageId] ?? 1 + } + + const formatTokenLabel = (tokens: number): string => { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M` + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K` + return String(tokens) + } + + const maxTokens = createMemo(() => { + let max = 0 + for (const s of props.segments) { + const tokens = getSegmentTokens(s) + if (tokens > max) max = tokens + } + return Math.max(max, 1) + }) + + // --- Long-press for mobile selection --- + let longPressTimer: number | null = null + let wasLongPress = false + let pressStartPos = { x: 0, y: 0 } + + const handlePointerDown = (id: string, event: PointerEvent) => { + if (event.button !== 0) return + wasLongPress = false + pressStartPos = { x: event.clientX, y: event.clientY } + + clearHoverTimer() + clearCloseTimer() + + if (longPressTimer !== null && typeof window !== "undefined") { + window.clearTimeout(longPressTimer) + } + + if (typeof window !== "undefined") { + longPressTimer = window.setTimeout(() => { + longPressTimer = null + wasLongPress = true + + // Scroll anchoring: preserve visual position of the pressed badge. + const btn = buttonRefs.get(id) + let anchorOffset: number | null = null + if (btn && scrollContainerRef) { + anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop + } + + props.onToggleSelection?.(id) + + if (anchorOffset !== null && btn && scrollContainerRef) { + const desired = btn.offsetTop - anchorOffset + if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) { + scrollContainerRef.scrollTop = desired + } + } + }, LONG_PRESS_MS) + } + } + + const handlePointerUp = () => { + if (longPressTimer !== null && typeof window !== "undefined") { + window.clearTimeout(longPressTimer) + longPressTimer = null + } + } + + const handlePointerMove = (event: PointerEvent) => { + if (longPressTimer !== null) { + const dist = Math.sqrt( + Math.pow(event.clientX - pressStartPos.x, 2) + + Math.pow(event.clientY - pressStartPos.y, 2), + ) + if (dist > JITTER_THRESHOLD) { + if (typeof window !== "undefined") { + window.clearTimeout(longPressTimer) + } + longPressTimer = null + } + } + } + + const handleContextMenu = (event: MouseEvent) => { + if (wasLongPress) { + event.preventDefault() + } + } + + createEffect(on(() => props.activeSegmentId, (activeId) => { if (!activeId) return - const targetSegment = untrack(() => props.segments).find((segment) => segment.messageId === activeId) - if (!targetSegment) return - const element = buttonRefs.get(targetSegment.id) + const element = buttonRefs.get(activeId) if (!element) return const timer = typeof window !== "undefined" ? window.setTimeout(() => { element.scrollIntoView({ block: "nearest", behavior: "smooth" }) @@ -402,120 +730,237 @@ const MessageTimeline: Component = (props) => { }) const previewData = createMemo(() => { - const segment = hoveredSegment() if (!segment) return null const record = store().getMessage(segment.messageId) if (!record) return null return { messageId: segment.messageId } }) - + return ( -