feat(ui): add timeline segment selection, xray token histogram, and group logic overhaul

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 `<For>`.
- `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 <noreply@anthropic.com>
This commit is contained in:
VooDisss
2026-03-02 09:51:59 +02:00
parent 482313f662
commit 224cab6a42
5 changed files with 1070 additions and 192 deletions

View File

@@ -14,7 +14,6 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
@@ -79,11 +78,177 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
const handleTimelineSegmentClick = (segment: TimelineSegment) => { const handleTimelineSegmentClick = (segment: TimelineSegment) => {
setLastSelectionAnchorId(segment.id)
setActiveSegmentId(segment.id)
if (typeof document === "undefined") return if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" }) anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
} }
const [selectedTimelineIds, setSelectedTimelineIds] = createSignal<Set<string>>(new Set())
const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal<string | null>(null)
const [expandedMessageIds, setExpandedMessageIds] = createSignal<Set<string>>(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<string>()
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<string>()
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<string>())
setLastSelectionAnchorId(null)
setExpandedMessageIds(new Set<string>())
}
const lastAssistantIndex = createMemo(() => { const lastAssistantIndex = createMemo(() => {
const ids = messageIds() const ids = messageIds()
const resolvedStore = store() const resolvedStore = store()
@@ -149,7 +314,7 @@ export default function MessageSection(props: MessageSectionProps) {
setTimelineSegments((prev) => [...prev, ...newSegments]) setTimelineSegments((prev) => [...prev, ...newSegments])
} }
} }
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null) const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" }) const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
@@ -157,6 +322,27 @@ export default function MessageSection(props: MessageSectionProps) {
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0) const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
const selectedDeleteCount = createMemo(() => selectedForDeletion().size) 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 isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => { const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
@@ -175,10 +361,37 @@ export default function MessageSection(props: MessageSectionProps) {
const clearDeleteMode = () => { const clearDeleteMode = () => {
setSelectedForDeletion(new Set<string>()) setSelectedForDeletion(new Set<string>())
setDeleteHover({ kind: "none" }) setDeleteHover({ kind: "none" })
setSelectedTimelineIds(new Set<string>())
setLastSelectionAnchorId(null)
setExpandedMessageIds(new Set<string>())
} }
createEffect(() => {
const timelineIds = selectedTimelineIds()
if (timelineIds.size === 0) {
setSelectedForDeletion(new Set<string>())
return
}
const segments = timelineSegments()
const affectedMessageIds = new Set<string>()
for (const segId of timelineIds) {
const segment = segments.find((s) => s.id === segId)
if (segment) affectedMessageIds.add(segment.messageId)
}
setSelectedForDeletion(affectedMessageIds)
})
const selectAllForDeletion = () => { const selectAllForDeletion = () => {
setSelectedForDeletion(new Set<string>(messageIds())) const allMessageIds = messageIds()
setSelectedForDeletion(new Set<string>(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<string>()
for (const seg of segments) {
if (seg.type === "tool") toolMessageIds.add(seg.messageId)
}
setExpandedMessageIds(toolMessageIds)
} }
const deleteSelectedMessages = async () => { const deleteSelectedMessages = async () => {
@@ -565,6 +778,7 @@ export default function MessageSection(props: MessageSectionProps) {
const ids = messageIds() const ids = messageIds()
if (loading) { if (loading) {
handleClearTimelineSelection()
previousTimelineIds = [] previousTimelineIds = []
setTimelineSegments([]) setTimelineSegments([])
seenTimelineMessageIds.clear() 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(() => { createEffect(() => {
const target = containerRef const target = containerRef
const loading = props.loading const loading = props.loading
@@ -873,7 +1098,10 @@ export default function MessageSection(props: MessageSectionProps) {
if (best) { if (best) {
const anchorId = (best.target as HTMLElement).id const anchorId = (best.target as HTMLElement).id
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId 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 }, { root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
@@ -1017,14 +1245,75 @@ export default function MessageSection(props: MessageSectionProps) {
</div> </div>
)} )}
</Show> </Show>
<Show when={isDeleteMode()}>
<div
class="message-delete-mode-toolbar"
role="toolbar"
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
>
<span class="message-delete-mode-token-group" aria-hidden="true">
<span class="message-delete-mode-count message-delete-mode-count--before" title={`${tokenStats().used} tokens currently in context`}>
{formatTokenCount(tokenStats().used)}
</span>
<span class="message-delete-mode-arrow" aria-hidden="true">{"\u203A"}</span>
<span class="message-delete-mode-count message-delete-mode-count--selection" title={`${selectedTokenTotal()} tokens selected (${selectedDeleteCount()} messages)`}>
{formatTokenCount(selectedTokenTotal())}
</span>
<span class="message-delete-mode-arrow" aria-hidden="true">{"\u203A"}</span>
<span class="message-delete-mode-count message-delete-mode-count--after" title={`${Math.max(0, tokenStats().used - selectedTokenTotal())} tokens remaining after deletion`}>
{formatTokenCount(Math.max(0, tokenStats().used - selectedTokenTotal()))}
</span>
</span>
<button
type="button"
class="message-delete-mode-button message-delete-mode-button--delete"
onClick={() => void deleteSelectedMessages()}
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
>
<Trash class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={selectAllForDeletion}
title={t("messageSection.bulkDelete.selectAllTitle")}
aria-label={t("messageSection.bulkDelete.selectAllTitle")}
>
<CheckSquare class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button message-delete-mode-button--cancel"
onClick={clearDeleteMode}
title={t("messageSection.bulkDelete.cancelTitle")}
aria-label={t("messageSection.bulkDelete.cancelTitle")}
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
<span class="message-delete-mode-hint keyboard-hints" aria-hidden="true">
{t("messageSection.bulkDelete.selectionHint")}
</span>
</div>
</Show>
</div> </div>
<Show when={hasTimelineSegments()}> <Show when={hasTimelineSegments()}>
<div class="message-timeline-sidebar"> <div class="message-timeline-sidebar">
<MessageTimeline <MessageTimeline
segments={timelineSegments()} segments={timelineSegments()}
onSegmentClick={handleTimelineSegmentClick} onSegmentClick={handleTimelineSegmentClick}
activeMessageId={activeMessageId()} onToggleSelection={handleToggleTimelineSelection}
onSelectRange={handleSelectRangeTimeline}
onClearSelection={handleClearTimelineSelection}
selectedIds={selectedTimelineIds}
expandedMessageIds={expandedMessageIds}
activeSegmentId={activeSegmentId()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()} showToolSegments={showTimelineToolsPreference()}
@@ -1036,48 +1325,6 @@ export default function MessageSection(props: MessageSectionProps) {
/> />
</div> </div>
</Show> </Show>
<Show when={isDeleteMode()}>
<div
class="message-delete-mode-toolbar"
role="toolbar"
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
>
<span class="message-delete-mode-count" aria-hidden="true">
{selectedDeleteCount()}
</span>
<button
type="button"
class="message-delete-mode-button"
onClick={() => void deleteSelectedMessages()}
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
>
<Trash class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={selectAllForDeletion}
title={t("messageSection.bulkDelete.selectAllTitle")}
aria-label={t("messageSection.bulkDelete.selectAllTitle")}
>
<CheckSquare class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={clearDeleteMode}
title={t("messageSection.bulkDelete.cancelTitle")}
aria-label={t("messageSection.bulkDelete.cancelTitle")}
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</Show>
</div> </div>
</div> </div>

View File

@@ -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 MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
@@ -22,12 +23,18 @@ export interface TimelineSegment {
toolPartIds?: string[] toolPartIds?: string[]
partIds?: string[] partIds?: string[]
partId?: string partId?: string
totalChars: number
} }
interface MessageTimelineProps { interface MessageTimelineProps {
segments: TimelineSegment[] segments: TimelineSegment[]
onSegmentClick?: (segment: TimelineSegment) => void onSegmentClick?: (segment: TimelineSegment) => void
activeMessageId?: string | null onToggleSelection?: (id: string) => void
onSelectRange?: (id: string) => void
onClearSelection?: () => void
selectedIds?: Accessor<Set<string>>
expandedMessageIds?: Accessor<Set<string>>
activeSegmentId?: string | null
instanceId: string instanceId: string
sessionId: string sessionId: string
showToolSegments?: boolean showToolSegments?: boolean
@@ -39,6 +46,9 @@ interface MessageTimelineProps {
} }
const MAX_TOOLTIP_LENGTH = 220 const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -47,6 +57,7 @@ interface PendingSegment {
texts: string[] texts: string[]
reasoningTexts: string[] reasoningTexts: string[]
partIds: string[] partIds: string[]
totalChars: number
hasPrimaryText: boolean hasPrimaryText: boolean
} }
@@ -57,6 +68,67 @@ function truncateText(value: string): string {
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}` 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 { function collectReasoningText(part: ClientPart): string {
const stringifySegment = (segment: unknown): string => { const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") { if (typeof segment === "string") {
@@ -182,7 +254,7 @@ export function buildTimelineSegments(
[...pending.texts, ...pending.reasoningTexts], [...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"), pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
) )
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -191,11 +263,12 @@ export function buildTimelineSegments(
tooltip, tooltip,
shortLabel, shortLabel,
partIds: pending.partIds, partIds: pending.partIds,
totalChars: pending.totalChars,
}) })
segmentIndex += 1 segmentIndex += 1
pending = null pending = null
} }
const ensureSegment = (type: TimelineSegmentType): PendingSegment => { const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) { if (!pending || pending.type !== type) {
flushPending() flushPending()
@@ -204,6 +277,7 @@ export function buildTimelineSegments(
texts: [], texts: [],
reasoningTexts: [], reasoningTexts: [],
partIds: [], partIds: [],
totalChars: 0,
hasPrimaryText: type !== "assistant", hasPrimaryText: type !== "assistant",
} }
} }
@@ -229,6 +303,7 @@ export function buildTimelineSegments(
tooltip: formatToolTooltip([title], t), tooltip: formatToolTooltip([title], t),
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"), shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
toolPartIds: partId ? [partId] : undefined, toolPartIds: partId ? [partId] : undefined,
totalChars: getPartCharCount(part),
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -243,10 +318,11 @@ export function buildTimelineSegments(
if (typeof (part as any).id === "string" && (part as any).id.length > 0) { if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id) target.partIds.push((part as any).id)
} }
target.totalChars += getPartCharCount(part)
} }
continue continue
} }
if (part.type === "compaction") { if (part.type === "compaction") {
flushPending() flushPending()
const isAuto = Boolean((part as any)?.auto) 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"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
partId, partId,
totalChars: 0,
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -267,7 +344,7 @@ export function buildTimelineSegments(
if (part.type === "step-start" || part.type === "step-finish") { if (part.type === "step-start" || part.type === "step-finish") {
continue continue
} }
const text = collectTextFromPart(part, t) const text = collectTextFromPart(part, t)
if (text.trim().length === 0) continue if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
@@ -277,12 +354,13 @@ export function buildTimelineSegments(
if (typeof (part as any).id === "string" && (part as any).id.length > 0) { if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id) target.partIds.push((part as any).id)
} }
target.totalChars += getPartCharCount(part)
} }
} }
flushPending() flushPending()
return result return result
} }
@@ -299,7 +377,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
let closeTimer: number | null = null let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const } const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
buttonRefs.set(segmentId, element) buttonRefs.set(segmentId, element)
@@ -307,7 +385,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
buttonRefs.delete(segmentId) buttonRefs.delete(segmentId)
} }
} }
const clearHoverTimer = () => { const clearHoverTimer = () => {
if (hoverTimer !== null && typeof window !== "undefined") { if (hoverTimer !== null && typeof window !== "undefined") {
window.clearTimeout(hoverTimer) window.clearTimeout(hoverTimer)
@@ -333,8 +411,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setHoverAnchorRect(null) setHoverAnchorRect(null)
}, 160) }, 160)
} }
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => { 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 if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
clearCloseTimer() clearCloseTimer()
@@ -349,7 +430,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const handleMouseLeave = () => { const handleMouseLeave = () => {
scheduleClose() scheduleClose()
} }
createEffect(() => { createEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const anchor = hoverAnchorRect() const anchor = hoverAnchorRect()
@@ -371,11 +452,258 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
clearCloseTimer() 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<Record<string, { layoutTop: number; height: number }>>({})
// 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<string, { layoutTop: number; height: number }> = {}
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<string, number>
const result: Record<string, number> = {}
const processedMessages = new Set<string>()
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<string, number>()
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 <For> item.
const aggregateTokensByMessageId = createMemo(() => {
const chars = liveSegmentChars()
const result: Record<string, number> = {}
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 if (!activeId) return
const targetSegment = untrack(() => props.segments).find((segment) => segment.messageId === activeId) const element = buttonRefs.get(activeId)
if (!targetSegment) return
const element = buttonRefs.get(targetSegment.id)
if (!element) return if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
@@ -402,120 +730,237 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
const previewData = createMemo(() => { const previewData = createMemo(() => {
const segment = hoveredSegment() const segment = hoveredSegment()
if (!segment) return null if (!segment) return null
const record = store().getMessage(segment.messageId) const record = store().getMessage(segment.messageId)
if (!record) return null if (!record) return null
return { messageId: segment.messageId } return { messageId: segment.messageId }
}) })
return ( return (
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}> <div class="message-timeline-container">
<For each={props.segments}> <div
{(segment) => { ref={scrollContainerRef}
onCleanup(() => buttonRefs.delete(segment.id)) class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
const isActive = () => props.activeMessageId === segment.messageId role="navigation"
aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScrollRaf}
>
<For each={props.segments}>
{(segment, segIndex) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id)
const isDeleteHovered = () => { const isDeleteHovered = () => {
const hover = deleteHover() as DeleteHoverState const hover = deleteHover() as DeleteHoverState
const selected = props.selectedMessageIds?.() ?? new Set<string>() if (hover.kind === "message") {
if (selected.has(segment.messageId)) { return hover.messageId === segment.messageId
return true }
}
if (hover.kind === "message") { if (hover.kind === "deleteUpTo") {
return hover.messageId === segment.messageId const ids = store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const segmentIndex = ids.indexOf(segment.messageId)
if (segmentIndex === -1) return false
return segmentIndex >= targetIndex
}
return false
} }
if (hover.kind === "deleteUpTo") { const hasActivePermission = () => {
const ids = store().getSessionMessageIds(props.sessionId) if (segment.type !== "tool") return false
const targetIndex = ids.indexOf(hover.messageId) const partIds = segment.toolPartIds ?? []
if (targetIndex === -1) return false if (partIds.length === 0) return false
const segmentIndex = ids.indexOf(segment.messageId) for (const partId of partIds) {
if (segmentIndex === -1) return false const permissionState = store().getPermissionState(segment.messageId, partId)
return segmentIndex >= targetIndex if (permissionState?.active) return true
}
return false
} }
return false const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
} const isHidden = () => segment.type === "tool" && !(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered())
const hasActivePermission = () => { // Group visual indicators: tools belong to the same message as their
if (segment.type !== "tool") return false // assistant. Uses messageId for correctness (not positional adjacency).
const partIds = segment.toolPartIds ?? [] const groupRole = (): "child" | "parent" | "none" => {
if (partIds.length === 0) return false if (segment.type === "tool") return "child"
for (const partId of partIds) { if (segment.type === "assistant") {
const permissionState = store().getPermissionState(segment.messageId, partId) const hasSiblingTools = props.segments.some(
if (permissionState?.active) return true (s) => s.messageId === segment.messageId && s.type === "tool",
)
if (hasSiblingTools) return "parent"
}
return "none"
}
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
} }
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission() || isDeleteHovered()) const shortLabelContent = () => {
if (segment.type === "tool") {
const shortLabelContent = () => { if (hasActivePermission()) {
if (segment.type === "tool") { return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
if (hasActivePermission()) { }
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> return segment.shortLabel ?? getToolIcon("tool")
} }
return segment.shortLabel ?? getToolIcon("tool") if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
data-variant={segment.variant} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`} class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
data-delete-hover={isDeleteHovered() ? "true" : undefined} data-delete-hover={isDeleteHovered() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)} onClick={(event) => {
onMouseEnter={(event) => handleMouseEnter(segment, event)} if (wasLongPress) {
onMouseLeave={handleMouseLeave} wasLongPress = false
> return
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> }
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button> // Capture scroll anchor before selection changes may toggle
) // tool segment visibility, which shifts timeline layout.
}} const btn = buttonRefs.get(segment.id)
</For> let anchorOffset: number | null = null
<Show when={previewData()}> if (btn && scrollContainerRef) {
{(data) => { anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
onCleanup(() => setTooltipElement(null)) }
return (
<div const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip" if (event.shiftKey) {
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} props.onSelectRange?.(segment.id)
onMouseEnter={() => clearCloseTimer()} } else if (event.ctrlKey || event.metaKey) {
onMouseLeave={() => scheduleClose()} props.onToggleSelection?.(segment.id)
> } else if (isMultiSelectActive) {
<MessagePreview props.onClearSelection?.()
messageId={data().messageId} } else {
instanceId={props.instanceId} props.onSegmentClick?.(segment)
sessionId={props.sessionId} }
store={store}
deleteHover={props.deleteHover} // Restore scroll anchor: keep the clicked badge at the same
onDeleteHoverChange={props.onDeleteHoverChange} // visual position after hidden tools appear or disappear.
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} if (anchorOffset !== null && btn && scrollContainerRef) {
selectedMessageIds={props.selectedMessageIds} const desired = btn.offsetTop - anchorOffset
/> if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
</div> scrollContainerRef.scrollTop = desired
) }
}} }
</Show> }}
onPointerDown={(e) => handlePointerDown(segment.id, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
)
}}
</For>
<Show when={previewData()}>
{(data) => {
onCleanup(() => setTooltipElement(null))
return (
<div
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()}
onMouseLeave={() => scheduleClose()}
>
<MessagePreview
messageId={data().messageId}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
/>
</div>
)
}}
</Show>
</div>
<Portal>
<Show when={isSelectionActive()}>
<div class="message-timeline-xray-overlay" style={{ "--max-rib-width": `${maxRibWidth()}px`, "clip-path": `inset(${clipBounds().top}px 0 ${(typeof window !== "undefined" ? window.innerHeight : 0) - clipBounds().bottom}px 0)` }}>
<For each={props.segments}>
{(segment) => {
// Derive screen position from stable layout offset + scroll state.
// Only arithmetic — no DOM reads per segment per scroll frame.
const pos = () => {
const offset = badgeOffsets()[segment.id]
if (!offset) return null
const scroll = containerScroll()
const top = scroll.containerTop + offset.layoutTop - scroll.scrollTop + offset.height / 2
const bounds = clipBounds()
if (top < bounds.top - 20 || top > bounds.bottom + 20) return null
return { top, left: scroll.left }
}
const tokens = () => getSegmentTokens(segment)
const relativeWeight = () => tokens() / maxTokens()
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
const isParent = segment.type === "assistant" || segment.type === "user"
const displayTokens = () =>
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
return (
<Show when={pos()}>
<div
class="message-timeline-xray-rib"
style={{
top: `${pos()!.top}px`,
left: `${pos()!.left}px`,
}}
>
<span class="message-timeline-xray-token-label">
{formatTokenLabel(displayTokens())}
</span>
<div
class="message-timeline-relative-bar"
style={{ "--segment-weight": relativeWeight() }}
/>
<div
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
style={{ "--segment-weight": absoluteWeight() }}
/>
</div>
</Show>
)
}}
</For>
</div>
</Show>
</Portal>
</div> </div>
) )
} }
export default MessageTimeline export default MessageTimeline

View File

@@ -23,7 +23,6 @@ export const messagingMessages = {
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",
"messageSection.quote.copied": "Copied!", "messageSection.quote.copied": "Copied!",
"messageSection.quote.copyFailed": "Copy failed", "messageSection.quote.copyFailed": "Copy failed",
"messageTimeline.ariaLabel": "Message timeline", "messageTimeline.ariaLabel": "Message timeline",
"messageTimeline.segment.user.label": "You", "messageTimeline.segment.user.label": "You",
"messageTimeline.segment.assistant.label": "Asst", "messageTimeline.segment.assistant.label": "Asst",
@@ -35,7 +34,6 @@ export const messagingMessages = {
"messageTimeline.tooltip.compaction.manual": "User Compaction", "messageTimeline.tooltip.compaction.manual": "User Compaction",
"messageTimeline.text.filePrefix": "[File] {filename}", "messageTimeline.text.filePrefix": "[File] {filename}",
"messageTimeline.text.attachment": "Attachment", "messageTimeline.text.attachment": "Attachment",
"messageBlock.tool.header": "Tool Call", "messageBlock.tool.header": "Tool Call",
"messageBlock.tool.unknown": "unknown", "messageBlock.tool.unknown": "unknown",
"messageBlock.tool.goToSession.label": "Go to Session", "messageBlock.tool.goToSession.label": "Go to Session",
@@ -91,6 +89,7 @@ export const messagingMessages = {
"messageSection.bulkDelete.cancelTitle": "Cancel selection", "messageSection.bulkDelete.cancelTitle": "Cancel selection",
"messageSection.bulkDelete.failedTitle": "Delete failed", "messageSection.bulkDelete.failedTitle": "Delete failed",
"messageSection.bulkDelete.failedMessage": "Failed to delete selected messages", "messageSection.bulkDelete.failedMessage": "Failed to delete selected messages",
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
"messageItem.status.queued": "QUEUED", "messageItem.status.queued": "QUEUED",
"messageItem.status.generating": "Generating...", "messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...", "messageItem.status.sending": "Sending...",

View File

@@ -11,37 +11,25 @@
.message-delete-mode-toolbar { .message-delete-mode-toolbar {
position: absolute; position: absolute;
right: 12px; right: 5rem;
bottom: 12px; bottom: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding: 6px; padding: 6px 10px;
background: color-mix(in oklab, var(--surface-secondary) 92%, var(--status-error-bg)); background: color-mix(in oklab, var(--surface-base) 62%, var(--accent-primary));
border: 1px solid var(--border-base); border: 1px solid var(--accent-primary);
border-radius: 12px; border-radius: 12px;
z-index: 50; z-index: 50;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); box-shadow:
0 0 0 1px color-mix(in oklab, var(--accent-primary) 25%, transparent),
0 8px 24px rgba(0, 0, 0, 0.3);
} }
/* Avoid covering the scroll-to-top/bottom floating buttons. */ .message-delete-mode-token-group {
.message-layout[data-scroll-buttons="1"] .message-delete-mode-toolbar { display: inline-flex;
bottom: 4.25rem; align-items: center;
} gap: 3px;
.message-layout[data-scroll-buttons="2"] .message-delete-mode-toolbar {
bottom: 7.5rem;
}
/* When timeline is visible, pin the toolbar to the stream edge. */
.message-layout--with-timeline .message-delete-mode-toolbar {
right: calc(64px + 12px);
}
@media (max-width: 720px) {
.message-layout--with-timeline .message-delete-mode-toolbar {
right: calc(40px + 12px);
}
} }
.message-delete-mode-count { .message-delete-mode-count {
@@ -52,11 +40,38 @@
justify-content: center; justify-content: center;
padding: 0 8px; padding: 0 8px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); font-variant-numeric: tabular-nums;
background: var(--surface-secondary); color: var(--accent-primary);
border: 1px solid var(--border-base); background: color-mix(in oklab, var(--surface-base) 85%, var(--accent-primary));
border: 1px solid color-mix(in oklab, var(--accent-primary) 50%, transparent);
}
.message-delete-mode-count--before {
color: var(--text-muted);
background: color-mix(in oklab, var(--surface-base) 90%, var(--text-muted));
border-color: color-mix(in oklab, var(--text-muted) 30%, transparent);
}
.message-delete-mode-count--selection {
color: var(--status-error);
background: color-mix(in oklab, var(--surface-base) 85%, var(--status-error));
border-color: color-mix(in oklab, var(--status-error) 40%, transparent);
}
.message-delete-mode-count--after {
color: var(--status-success);
background: color-mix(in oklab, var(--surface-base) 85%, var(--status-success));
border-color: color-mix(in oklab, var(--status-success) 40%, transparent);
}
.message-delete-mode-arrow {
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
user-select: none;
} }
.message-delete-mode-button { .message-delete-mode-button {
@@ -66,19 +81,45 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: transparent; background: transparent;
border: 1px solid var(--border-base); border: 1px solid color-mix(in oklab, var(--accent-primary) 30%, transparent);
border-radius: 10px; border-radius: 10px;
color: var(--text-muted); color: var(--text-secondary);
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
} }
.message-delete-mode-button:hover { .message-delete-mode-button:hover {
background-color: var(--surface-hover); background-color: color-mix(in oklab, var(--accent-primary) 15%, transparent);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.message-delete-mode-button--delete {
color: var(--status-error);
border-color: color-mix(in oklab, var(--status-error) 30%, transparent);
}
.message-delete-mode-button--delete:hover {
background-color: var(--status-error-bg);
border-color: var(--status-error); border-color: var(--status-error);
color: var(--status-error); color: var(--status-error);
} }
.message-delete-mode-button--cancel:hover {
background-color: color-mix(in oklab, var(--text-muted) 12%, transparent);
border-color: var(--text-muted);
color: var(--text-primary);
}
.message-delete-mode-button:focus-visible { .message-delete-mode-button:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent);
} }
.message-delete-mode-hint {
margin-left: 2px;
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
user-select: none;
}

View File

@@ -6,6 +6,9 @@
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
position: relative; position: relative;
/* Isolate stacking context so sidebar z-indices don't compete with
Portals (Command Palette, modals) that live at the body level. */
isolation: isolate;
} }
.message-layout--with-timeline { .message-layout--with-timeline {
@@ -51,6 +54,8 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
z-index: 100;
} }
@@ -67,11 +72,16 @@
gap: 0.35rem; gap: 0.35rem;
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto; overflow-y: auto;
overflow-x: visible;
border-radius: 8px; border-radius: 8px;
background-color: var(--surface-base); background-color: var(--surface-base);
box-shadow: var(--panel-shadow); box-shadow: var(--panel-shadow);
} }
.message-timeline--selection-active {
padding-bottom: 4rem;
}
.message-timeline::-webkit-scrollbar { .message-timeline::-webkit-scrollbar {
width: 5px; width: 5px;
} }
@@ -97,6 +107,11 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--text-primary); color: var(--text-primary);
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-touch-callout: none;
} }
.message-timeline-segment[data-delete-hover="true"]::before { .message-timeline-segment[data-delete-hover="true"]::before {
@@ -259,3 +274,134 @@
.message-preview .message-item-base { .message-preview .message-item-base {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* --- Selection & Histogram Ribs --- */
.message-timeline-segment-selected {
border-color: var(--accent-primary) !important;
background-color: color-mix(in oklab, var(--accent-primary) 25%, var(--surface-base)) !important;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 50%, transparent) inset !important;
color: var(--accent-primary) !important;
}
.message-timeline-segment-selected:hover,
.message-timeline-segment-selected:focus-visible {
background-color: color-mix(in oklab, var(--accent-primary) 35%, var(--surface-base)) !important;
color: var(--accent-primary) !important;
transform: none;
}
/* --- Group indicators: tools belong to the same message as their assistant --- */
/* Tool segments that are part of a group get a left accent border. */
.message-timeline-group-child {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
}
/* The assistant "parent" at the bottom of a tool group gets the same border. */
.message-timeline-group-parent {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
}
/* Extra spacing before the first tool in a group to separate from the
preceding user/assistant badge. */
.message-timeline-group-start {
margin-top: 0.35rem;
}
/* Subtle extra spacing after the group parent (assistant) to separate
from the next user badge below. Uses adjacent sibling targeting. */
.message-timeline-group-parent + .message-timeline-user,
.message-timeline-group-parent + .message-timeline-compaction {
margin-top: 0.35rem;
}
.message-timeline-container {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-timeline-xray-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
/* Below Command Palette (z-50) but above normal content. */
z-index: 40;
}
.message-timeline-xray-rib {
position: fixed;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
transform: translate(-100%, -50%);
}
.message-timeline-xray-token-label {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 4px;
height: 1.5rem;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
color: #1a1a2e;
background: #ffffff;
padding: 1px 5px;
border: 1px solid #1a1a2e;
border-radius: 999px;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.message-timeline-relative-bar {
height: 5px;
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
background-color: color-mix(
in srgb,
var(--status-success) calc(100% - var(--segment-weight) * 100%),
var(--status-error) calc(var(--segment-weight) * 100%)
);
border-radius: 3px 0 0 3px;
transition: width 0.3s ease, background-color 0.3s ease;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.25);
}
.message-timeline-absolute-bar {
height: 3px;
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
background-color: var(--text-muted);
border-radius: 2px 0 0 2px;
transition: width 0.3s ease;
opacity: 0.5;
position: relative;
}
.message-timeline-absolute-bar-overflow {
opacity: 0.8;
}
.message-timeline-absolute-bar-overflow::before {
content: "";
position: absolute;
left: -1px;
top: -3px;
bottom: -3px;
width: 3px;
border-radius: 2px;
background: var(--status-error);
box-shadow: 0 0 6px 2px var(--status-error);
}