diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 84b35839..9bafdb39 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" -import { CheckSquare, Trash, X } from "lucide-solid" +import { MoreHorizontal, Trash, X } from "lucide-solid" import Kbd from "./kbd" import MessageBlock from "./message-block" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" @@ -13,9 +13,11 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" -import { deleteMessage } from "../stores/session-actions" +import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" +import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" +import { getPartCharCount } from "../lib/token-utils" const SCROLL_SENTINEL_MARGIN_PX = 48 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" @@ -85,16 +87,234 @@ export default function MessageSection(props: MessageSectionProps) { }) const handleTimelineSegmentClick = (segment: TimelineSegment) => { - const api = listApi() - if (api) { - api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" }) + const scrollToMessage = () => { + const api = listApi() + if (api) { + api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" }) + return + } + if (typeof document === "undefined") return + const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) + anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) + } + + if (selectionMode() === "tools" && segment.type !== "tool") { + setActiveSegmentId(segment.id) + scrollToMessage() return } - if (typeof document === "undefined") return - const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) - anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) + + setLastSelectionAnchorId(segment.id) + setActiveSegmentId(segment.id) + scrollToMessage() } - + + const [selectedTimelineIds, setSelectedTimelineIds] = createSignal>(new Set()) + const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal(null) + const [expandedMessageIds, setExpandedMessageIds] = createSignal>(new Set()) + const [selectionMode, setSelectionMode] = createSignal<"all" | "tools">("all") + const [isDeleteMenuOpen, setIsDeleteMenuOpen] = createSignal(false) + let deleteMenuRef: HTMLDivElement | undefined + let deleteMenuButtonRef: HTMLButtonElement | undefined + + // Deletion is only allowed for messages/tool parts that occur AFTER the most + // recent compaction. Compaction effectively resets the stored context; deleting + // earlier items would not reliably reflect what the model sees. + const messageIndexById = createMemo(() => { + const ids = messageIds() + const map = new Map() + for (let i = 0; i < ids.length; i++) { + map.set(ids[i], i) + } + return map + }) + + const lastCompactionIndex = createMemo(() => { + // Depend on a single session revision signal (not every message/part read) + // to keep reactive overhead small. + sessionRevision() + return untrack(() => store().getLastCompactionMessageIndex(props.sessionId)) + }) + + const deletableStartIndex = createMemo(() => { + const idx = lastCompactionIndex() + return idx === -1 ? 0 : idx + 1 + }) + + const deletableMessageIds = createMemo(() => { + const ids = messageIds() + const start = deletableStartIndex() + return new Set(ids.slice(start)) + }) + + const isMessageDeletable = (messageId: string): boolean => { + const idx = messageIndexById().get(messageId) + if (idx === undefined) return false + return idx >= deletableStartIndex() + } + + // Build the message group for a segment. + // Tool calls belong to the same assistant turn (between user messages). + // Only assistant badges trigger group selection; user/tool badges are standalone. + const getAdjacentGroup = (_clickedIndex: number, segments: TimelineSegment[]): TimelineSegment[] => { + const clicked = segments[_clickedIndex] + if (clicked.type === "assistant") { + let currentTurn = -1 + const turnByMessageId = new Map() + for (const segment of segments) { + if (segment.type === "user") { + currentTurn += 1 + continue + } + if (currentTurn === -1) currentTurn = 0 + if (!turnByMessageId.has(segment.messageId)) { + turnByMessageId.set(segment.messageId, currentTurn) + } + } + const turnIndex = turnByMessageId.get(clicked.messageId) + if (turnIndex === undefined) { + return segments.filter((s) => s.messageId === clicked.messageId) + } + return segments.filter((s) => s.type !== "user" && turnByMessageId.get(s.messageId) === turnIndex) + } + // User, tool, and compaction segments are standalone. + return [clicked] + } + + const handleToggleTimelineSelection = (id: string) => { + const segments = timelineSegments() + const segmentIndex = segments.findIndex((s) => s.id === id) + if (segmentIndex === -1) return + const segment = segments[segmentIndex] + + if (!isMessageDeletable(segment.messageId)) { + return + } + + setLastSelectionAnchorId(id) + + if (selectionMode() === "tools" && segment.type !== "tool") { + return + } + + const selected = selectedTimelineIds() + const isCurrentlySelected = selected.has(id) + const group = getAdjacentGroup(segmentIndex, segments) + const hasToolsInGroup = group.some((s) => s.type === "tool") + const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup + const selectedInGroup = isGroupCandidate + ? group.reduce((count, s) => (selected.has(s.id) ? count + 1 : count), 0) + : 0 + const isGroupEmpty = isGroupCandidate && selectedInGroup === 0 + + if (isGroupCandidate && !isCurrentlySelected && isGroupEmpty) { + // Parent click: select entire group only when none are selected yet. + // Tool visibility is handled by isSelectionActive() in isHidden() — no + // expand/collapse needed. + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + for (const s of group) next.add(s.id) + return next + }) + } else if (isCurrentlySelected) { + // Individual deselect (tool or parent). No group deselect. + const newSelected = new Set(selected) + newSelected.delete(id) + setSelectedTimelineIds(newSelected) + } else { + // Individual select (tool badge, parent with partial group, or standalone). + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + next.add(id) + return next + }) + } + } + + const handleLongPressTimelineSelection = (segment: TimelineSegment) => { + const segments = timelineSegments() + const segmentIndex = segments.findIndex((s) => s.id === segment.id) + if (segmentIndex === -1) return + + if (!isMessageDeletable(segment.messageId)) { + return + } + + setLastSelectionAnchorId(segment.id) + + if (selectionMode() === "tools" && segment.type !== "tool") { + return + } + const group = getAdjacentGroup(segmentIndex, segments) + const hasToolsInGroup = group.some((s) => s.type === "tool") + const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup + if (!isGroupCandidate) { + handleToggleTimelineSelection(segment.id) + return + } + const selected = selectedTimelineIds() + const hasAnySelected = group.some((s) => selected.has(s.id)) + if (!hasAnySelected) { + setSelectedTimelineIds((prev) => { + const next = new Set(prev) + for (const s of group) next.add(s.id) + return next + }) + return + } + const newSelected = new Set(selected) + for (const s of group) newSelected.delete(s.id) + setSelectedTimelineIds(newSelected) + } + + 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) + + const rangeSegments = selectionMode() === "tools" + ? segments.slice(start, end + 1).filter((s) => s.type === "tool" && isMessageDeletable(s.messageId)) + : segments.slice(start, end + 1).filter((s) => isMessageDeletable(s.messageId)) + // Range selection replaces current selection so it can grow or shrink. + setSelectedTimelineIds(new Set(rangeSegments.map((segment) => segment.id))) + } + + const handleClearTimelineSelection = () => { + setSelectedTimelineIds(new Set()) + setLastSelectionAnchorId(null) + } + + const applySelectionMode = (mode: "all" | "tools") => { + setSelectionMode(mode) + if (mode !== "tools") return + const segments = timelineSegments() + const toolIds = new Set( + segments + .filter((segment) => segment.type === "tool" && isMessageDeletable(segment.messageId)) + .map((segment) => segment.id), + ) + setSelectedTimelineIds((prev) => { + if (prev.size === 0) return prev + const next = new Set([...prev].filter((id) => toolIds.has(id))) + if (next.size === 0) setLastSelectionAnchorId(null) + return next + }) + } + const lastAssistantIndex = createMemo(() => { const ids = messageIds() const resolvedStore = store() @@ -160,18 +380,102 @@ 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" }) const [selectedForDeletion, setSelectedForDeletion] = createSignal>(new Set()) - const isDeleteMode = createMemo(() => selectedForDeletion().size > 0) - const selectedDeleteCount = createMemo(() => selectedForDeletion().size) + const selectedToolParts = createMemo(() => { + const selected = selectedTimelineIds() + if (selected.size === 0) return [] as { messageId: string; partId: string }[] + const segments = timelineSegments() + const segmentById = new Map() + for (const segment of segments) segmentById.set(segment.id, segment) + const toolParts: { messageId: string; partId: string }[] = [] + const seen = new Set() + for (const segId of selected) { + const segment = segmentById.get(segId) + if (!segment || segment.type !== "tool") continue + for (const partId of segment.toolPartIds ?? []) { + if (!partId) continue + const key = `${segment.messageId}:${partId}` + if (seen.has(key)) continue + seen.add(key) + toolParts.push({ messageId: segment.messageId, partId }) + } + } + return toolParts + }) + const deleteMessageIds = createMemo(() => selectedForDeletion()) + const deleteToolParts = createMemo(() => { + const messageIds = deleteMessageIds() + const allowed = deletableMessageIds() + return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId)) + }) + const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0) + const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length) + + const selectedTokenTotal = createMemo(() => { + const selected = deleteMessageIds() + const toolParts = deleteToolParts() + if (selected.size === 0 && toolParts.length === 0) return 0 + // Fresh-from-store chars: read parts directly via buildRecordDisplayData + + // getPartCharCount so the toolbar stays consistent with the xray overlay + // (which also reads live from the store). Falls back to segment totalChars + // when no record is found (e.g. compaction segments). + const s = store() + let total = 0 + for (const messageId of selected) { + let chars = 0 + const record = s.getMessage(messageId) + if (record) { + const displayData = buildRecordDisplayData(props.instanceId, record) + for (const part of displayData.orderedParts) { + chars += getPartCharCount(part) + } + } else { + // Fallback: sum from segments (O(n) pre-pass scoped to this branch) + for (const seg of timelineSegments()) { + if (seg.messageId === messageId) chars += seg.totalChars + } + } + total += Math.max(Math.round(chars / 4), 1) + } + if (toolParts.length > 0) { + const partFallbackChars = new Map() + for (const segment of timelineSegments()) { + if (segment.type !== "tool") continue + for (const partId of segment.toolPartIds ?? []) { + if (!partId || partFallbackChars.has(partId)) continue + partFallbackChars.set(partId, segment.totalChars) + } + } + for (const { messageId, partId } of toolParts) { + let chars = 0 + const record = s.getMessage(messageId) + const partRecord = record?.parts?.[partId] + if (partRecord?.data) { + chars = getPartCharCount(partRecord.data) + } else { + chars = partFallbackChars.get(partId) ?? 0 + } + total += Math.max(Math.round(chars / 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) => { if (!messageId) return + if (!isMessageDeletable(messageId)) return setSelectedForDeletion((prev) => { const next = new Set(prev) if (selected) { @@ -186,21 +490,50 @@ export default function MessageSection(props: MessageSectionProps) { const clearDeleteMode = () => { setSelectedForDeletion(new Set()) setDeleteHover({ kind: "none" }) + setSelectedTimelineIds(new Set()) + setLastSelectionAnchorId(null) } + createEffect(() => { + const timelineIds = selectedTimelineIds() + if (timelineIds.size === 0) { + setSelectedForDeletion(new Set()) + return + } + const segments = timelineSegments() + const segmentById = new Map() + for (const segment of segments) segmentById.set(segment.id, segment) + const affectedMessageIds = new Set() + for (const segId of timelineIds) { + const segment = segmentById.get(segId) + if (segment && segment.type !== "tool" && isMessageDeletable(segment.messageId)) { + affectedMessageIds.add(segment.messageId) + } + } + setSelectedForDeletion(affectedMessageIds) + }) + const selectAllForDeletion = () => { - setSelectedForDeletion(new Set(messageIds())) + const allMessageIds = [...deletableMessageIds()] + setSelectedForDeletion(new Set(allMessageIds)) + // Also select all timeline segments — tool visibility is handled by + // isSelectionActive() in isHidden(), no expand/collapse needed. + const segments = timelineSegments() + setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id))) } const deleteSelectedMessages = async () => { - const selected = selectedForDeletion() - if (selected.size === 0) return + const selected = deleteMessageIds() + const toolParts = deleteToolParts() + if (selected.size === 0 && toolParts.length === 0) return + + const allowed = deletableMessageIds() const idsInSessionOrder = messageIds() const toDelete: string[] = [] for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) { const id = idsInSessionOrder[idx] - if (selected.has(id)) { + if (allowed.has(id) && selected.has(id)) { toDelete.push(id) } } @@ -209,6 +542,10 @@ export default function MessageSection(props: MessageSectionProps) { for (const messageId of toDelete) { await deleteMessage(props.instanceId, props.sessionId, messageId) } + for (const { messageId, partId } of toolParts) { + if (!allowed.has(messageId)) continue + await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId) + } clearDeleteMode() } catch (error) { showAlertDialog(t("messageSection.bulkDelete.failedMessage"), { @@ -391,6 +728,7 @@ export default function MessageSection(props: MessageSectionProps) { const ids = messageIds() if (loading) { + handleClearTimelineSelection() previousTimelineIds = [] setTimelineSegments([]) seenTimelineMessageIds.clear() @@ -524,6 +862,14 @@ export default function MessageSection(props: MessageSectionProps) { next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) return next }) + + // Prune stale selection IDs: segment IDs are positional and change on rebuild. + setSelectedTimelineIds((prev) => { + if (prev.size === 0) return prev + const currentIds = new Set(timelineSegments().map((s) => s.id)) + const pruned = new Set([...prev].filter((id) => currentIds.has(id))) + return pruned.size === prev.size ? prev : pruned + }) }) } @@ -596,6 +942,29 @@ 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(() => { + if (!isDeleteMenuOpen()) return + if (typeof document === "undefined") return + const handleClick = (event: MouseEvent) => { + const target = event.target as Node + if (deleteMenuRef?.contains(target)) return + if (deleteMenuButtonRef?.contains(target)) return + setIsDeleteMenuOpen(false) + } + document.addEventListener("mousedown", handleClick) + onCleanup(() => document.removeEventListener("mousedown", handleClick)) + }) onCleanup(() => { clearPendingTimelinePartUpdateFrame() clearQuoteSelection() @@ -612,31 +981,37 @@ export default function MessageSection(props: MessageSectionProps) { class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`} data-scroll-buttons={scrollButtonsCount()} > - messageId} - getAnchorId={getMessageAnchorId} - getKeyFromAnchorId={getMessageIdFromAnchorId} - overscanPx={800} - scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} - suspendMeasurements={() => !isActive()} - loading={() => Boolean(props.loading)} - isActive={isActive} - scrollToBottomOnActivate={() => false} - initialScrollToBottom={() => false} - initialAutoScroll={initialAutoScroll} - resetKey={() => props.sessionId} - followToken={followToken} - onScroll={() => { - clearQuoteSelection() - scrollCache.persist(streamElement()) - }} - onMouseUp={() => handleStreamMouseUp()} - onActiveKeyChange={setActiveMessageId} - onScrollElementChange={(element) => { - setStreamElement(element) - if (!element) clearQuoteSelection() - }} + messageId} + getAnchorId={getMessageAnchorId} + getKeyFromAnchorId={getMessageIdFromAnchorId} + overscanPx={800} + scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} + suspendMeasurements={() => !isActive()} + loading={() => Boolean(props.loading)} + isActive={isActive} + scrollToBottomOnActivate={() => false} + initialScrollToBottom={() => false} + initialAutoScroll={initialAutoScroll} + resetKey={() => props.sessionId} + followToken={followToken} + onScroll={() => { + clearQuoteSelection() + scrollCache.persist(streamElement()) + }} + onMouseUp={() => handleStreamMouseUp()} + onActiveKeyChange={(messageId) => { + if (!messageId) return + const firstSeg = timelineSegments().find((s) => s.messageId === messageId) + if (firstSeg) { + setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id)) + } + }} + onScrollElementChange={(element) => { + setStreamElement(element) + if (!element) clearQuoteSelection() + }} onShellElementChange={(element) => { setStreamShellElement(element) if (!element) clearQuoteSelection() @@ -651,12 +1026,7 @@ export default function MessageSection(props: MessageSectionProps) {
- {t("messageSection.empty.logoAlt")} + {t("messageSection.empty.logoAlt")}

{t("messageSection.empty.brandTitle")}

{t("messageSection.empty.title")}

@@ -725,12 +1095,138 @@ 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 20e5328f..0a1328d7 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -1,9 +1,10 @@ -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 MessagePreview from "./message-preview" import { messageStoreBus } from "../stores/message-v2/bus" import type { ClientPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" +import { getPartCharCount } from "../lib/token-utils" import { getToolIcon } from "./tool-call/utils" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { useI18n } from "../lib/i18n" @@ -22,12 +23,22 @@ 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 + onLongPressSelection?: (segment: TimelineSegment) => void + onSelectRange?: (id: string) => void + onClearSelection?: () => void + selectedIds?: Accessor> + expandedMessageIds?: Accessor> + // Optional: restrict histogram/xray overlay to only show for these message ids. + // Used to hide ribs for messages before the last compaction. + deletableMessageIds?: Accessor> + activeSegmentId?: string | null instanceId: string sessionId: string showToolSegments?: boolean @@ -39,6 +50,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 +61,7 @@ interface PendingSegment { texts: string[] reasoningTexts: string[] partIds: string[] + totalChars: number hasPrimaryText: boolean } @@ -182,7 +197,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 +206,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 +220,7 @@ export function buildTimelineSegments( texts: [], reasoningTexts: [], partIds: [], + totalChars: 0, hasPrimaryText: type !== "assistant", } } @@ -229,6 +246,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 +261,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 +278,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 +287,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 +297,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 +320,13 @@ const MessageTimeline: Component = (props) => { let closeTimer: number | null = null const showTools = () => props.showToolSegments ?? true const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const } - + + const isHistogramEligible = (segment: TimelineSegment): boolean => { + const allowed = props.deletableMessageIds?.() + if (!allowed) return true + return allowed.has(segment.messageId) + } + const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { if (element) { buttonRefs.set(segmentId, element) @@ -307,7 +334,7 @@ const MessageTimeline: Component = (props) => { buttonRefs.delete(segmentId) } } - + const clearHoverTimer = () => { if (hoverTimer !== null && typeof window !== "undefined") { window.clearTimeout(hoverTimer) @@ -333,8 +360,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 +379,7 @@ const MessageTimeline: Component = (props) => { const handleMouseLeave = () => { scheduleClose() } - + createEffect(() => { if (typeof window === "undefined") return const anchor = hoverAnchorRect() @@ -371,11 +401,235 @@ const MessageTimeline: Component = (props) => { clearCloseTimer() }) - createEffect(on(() => props.activeMessageId, (activeId) => { + // --- Selection & histogram rib state --- + const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0) + + // Segments eligible for xray ribs. We intentionally exclude messages before + // the last compaction (when provided by the parent) to avoid misleading token + // weights for content that's no longer in context. + const xraySegments = createMemo(() => { + if (!isSelectionActive()) return [] as TimelineSegment[] + return props.segments.filter((segment) => isHistogramEligible(segment)) + }) + + // 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>({}) + const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) + let scrollContainerRef: HTMLDivElement | undefined + let xrayOverlayRef: HTMLDivElement | undefined + + // 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) + if (xrayOverlayRef) { + xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`) + } + + if (typeof window !== "undefined") { + setWindowWidth(window.innerWidth) + } + } + + const handleScroll = () => { + if (!isSelectionActive()) return + if (!scrollContainerRef || !xrayOverlayRef) return + xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) + } + + createEffect(() => { + if (isSelectionActive()) { + computeBadgeLayout() + if (typeof window !== "undefined") { + // 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) + }) + } + } + }) + + // 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 resolvedStore = store() + + // Compute live char counts by reading only the parts that the segment + // references (partIds/toolPartIds). This stays accurate for streamed tool + // outputs without scanning every part in the message. + for (const segment of xraySegments()) { + const record = resolvedStore.getMessage(segment.messageId) + if (!record) { + result[segment.id] = segment.totalChars + continue + } + + const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])] + let chars = 0 + for (const partId of ids) { + const part = record.parts?.[partId]?.data + if (!part) continue + chars += getPartCharCount(part) + } + + result[segment.id] = chars > 0 ? chars : segment.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 xraySegments()) { + 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 + // When tools are hidden (not expanded, not in selection mode), assistant/user + // bars show aggregate tokens for the whole message. When tools are visible + // (expanded or selection mode active), each segment shows its own tokens to + // avoid double-counting. + if (!isExpanded && !isSelectionActive() && (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 xraySegments()) { + 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 = (segment: TimelineSegment, 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(segment.id) + let anchorOffset: number | null = null + if (btn && scrollContainerRef) { + anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop + } + + if (props.onLongPressSelection) { + props.onLongPressSelection(segment) + } else { + props.onToggleSelection?.(segment.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,127 +656,257 @@ 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 } }) - + + // Pre-computed set of messageIds that have at least one tool segment. + // Used by groupRole() inside to avoid O(n) .some() per segment → O(1) .has(). + const messagesWithTools = createMemo(() => { + const set = new Set() + for (const s of props.segments) { + if (s.type === "tool") set.add(s.messageId) + } + return set + }) + + // Pre-computed index map for session message ordering. + // Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get(). + const messageIdToSessionIndex = createMemo(() => { + const ids = store().getSessionMessageIds(props.sessionId) + const map = new Map() + for (let i = 0; i < ids.length; i++) map.set(ids[i], i) + return map + }) + return ( -