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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user