Merge origin/dev into dev
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||
import { CheckSquare, Trash, X } from "lucide-solid"
|
||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||
@@ -13,9 +13,11 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
@@ -85,16 +87,234 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
const api = listApi()
|
||||
if (api) {
|
||||
api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" })
|
||||
const scrollToMessage = () => {
|
||||
const api = listApi()
|
||||
if (api) {
|
||||
api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" })
|
||||
return
|
||||
}
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
if (selectionMode() === "tools" && segment.type !== "tool") {
|
||||
setActiveSegmentId(segment.id)
|
||||
scrollToMessage()
|
||||
return
|
||||
}
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
|
||||
setLastSelectionAnchorId(segment.id)
|
||||
setActiveSegmentId(segment.id)
|
||||
scrollToMessage()
|
||||
}
|
||||
|
||||
|
||||
const [selectedTimelineIds, setSelectedTimelineIds] = createSignal<Set<string>>(new Set())
|
||||
const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal<string | null>(null)
|
||||
const [expandedMessageIds, setExpandedMessageIds] = createSignal<Set<string>>(new Set())
|
||||
const [selectionMode, setSelectionMode] = createSignal<"all" | "tools">("all")
|
||||
const [isDeleteMenuOpen, setIsDeleteMenuOpen] = createSignal(false)
|
||||
let deleteMenuRef: HTMLDivElement | undefined
|
||||
let deleteMenuButtonRef: HTMLButtonElement | undefined
|
||||
|
||||
// Deletion is only allowed for messages/tool parts that occur AFTER the most
|
||||
// recent compaction. Compaction effectively resets the stored context; deleting
|
||||
// earlier items would not reliably reflect what the model sees.
|
||||
const messageIndexById = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
map.set(ids[i], i)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const lastCompactionIndex = createMemo(() => {
|
||||
// Depend on a single session revision signal (not every message/part read)
|
||||
// to keep reactive overhead small.
|
||||
sessionRevision()
|
||||
return untrack(() => store().getLastCompactionMessageIndex(props.sessionId))
|
||||
})
|
||||
|
||||
const deletableStartIndex = createMemo(() => {
|
||||
const idx = lastCompactionIndex()
|
||||
return idx === -1 ? 0 : idx + 1
|
||||
})
|
||||
|
||||
const deletableMessageIds = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const start = deletableStartIndex()
|
||||
return new Set(ids.slice(start))
|
||||
})
|
||||
|
||||
const isMessageDeletable = (messageId: string): boolean => {
|
||||
const idx = messageIndexById().get(messageId)
|
||||
if (idx === undefined) return false
|
||||
return idx >= deletableStartIndex()
|
||||
}
|
||||
|
||||
// Build the message group for a segment.
|
||||
// Tool calls belong to the same assistant turn (between user messages).
|
||||
// Only assistant badges trigger group selection; user/tool badges are standalone.
|
||||
const getAdjacentGroup = (_clickedIndex: number, segments: TimelineSegment[]): TimelineSegment[] => {
|
||||
const clicked = segments[_clickedIndex]
|
||||
if (clicked.type === "assistant") {
|
||||
let currentTurn = -1
|
||||
const turnByMessageId = new Map<string, number>()
|
||||
for (const segment of segments) {
|
||||
if (segment.type === "user") {
|
||||
currentTurn += 1
|
||||
continue
|
||||
}
|
||||
if (currentTurn === -1) currentTurn = 0
|
||||
if (!turnByMessageId.has(segment.messageId)) {
|
||||
turnByMessageId.set(segment.messageId, currentTurn)
|
||||
}
|
||||
}
|
||||
const turnIndex = turnByMessageId.get(clicked.messageId)
|
||||
if (turnIndex === undefined) {
|
||||
return segments.filter((s) => s.messageId === clicked.messageId)
|
||||
}
|
||||
return segments.filter((s) => s.type !== "user" && turnByMessageId.get(s.messageId) === turnIndex)
|
||||
}
|
||||
// User, tool, and compaction segments are standalone.
|
||||
return [clicked]
|
||||
}
|
||||
|
||||
const handleToggleTimelineSelection = (id: string) => {
|
||||
const segments = timelineSegments()
|
||||
const segmentIndex = segments.findIndex((s) => s.id === id)
|
||||
if (segmentIndex === -1) return
|
||||
const segment = segments[segmentIndex]
|
||||
|
||||
if (!isMessageDeletable(segment.messageId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setLastSelectionAnchorId(id)
|
||||
|
||||
if (selectionMode() === "tools" && segment.type !== "tool") {
|
||||
return
|
||||
}
|
||||
|
||||
const selected = selectedTimelineIds()
|
||||
const isCurrentlySelected = selected.has(id)
|
||||
const group = getAdjacentGroup(segmentIndex, segments)
|
||||
const hasToolsInGroup = group.some((s) => s.type === "tool")
|
||||
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
|
||||
const selectedInGroup = isGroupCandidate
|
||||
? group.reduce((count, s) => (selected.has(s.id) ? count + 1 : count), 0)
|
||||
: 0
|
||||
const isGroupEmpty = isGroupCandidate && selectedInGroup === 0
|
||||
|
||||
if (isGroupCandidate && !isCurrentlySelected && isGroupEmpty) {
|
||||
// Parent click: select entire group only when none are selected yet.
|
||||
// Tool visibility is handled by isSelectionActive() in isHidden() — no
|
||||
// expand/collapse needed.
|
||||
setSelectedTimelineIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const s of group) next.add(s.id)
|
||||
return next
|
||||
})
|
||||
} else if (isCurrentlySelected) {
|
||||
// Individual deselect (tool or parent). No group deselect.
|
||||
const newSelected = new Set(selected)
|
||||
newSelected.delete(id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
} else {
|
||||
// Individual select (tool badge, parent with partial group, or standalone).
|
||||
setSelectedTimelineIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLongPressTimelineSelection = (segment: TimelineSegment) => {
|
||||
const segments = timelineSegments()
|
||||
const segmentIndex = segments.findIndex((s) => s.id === segment.id)
|
||||
if (segmentIndex === -1) return
|
||||
|
||||
if (!isMessageDeletable(segment.messageId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setLastSelectionAnchorId(segment.id)
|
||||
|
||||
if (selectionMode() === "tools" && segment.type !== "tool") {
|
||||
return
|
||||
}
|
||||
const group = getAdjacentGroup(segmentIndex, segments)
|
||||
const hasToolsInGroup = group.some((s) => s.type === "tool")
|
||||
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
|
||||
if (!isGroupCandidate) {
|
||||
handleToggleTimelineSelection(segment.id)
|
||||
return
|
||||
}
|
||||
const selected = selectedTimelineIds()
|
||||
const hasAnySelected = group.some((s) => selected.has(s.id))
|
||||
if (!hasAnySelected) {
|
||||
setSelectedTimelineIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const s of group) next.add(s.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
const newSelected = new Set(selected)
|
||||
for (const s of group) newSelected.delete(s.id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
}
|
||||
|
||||
const handleSelectRangeTimeline = (id: string) => {
|
||||
const anchorId = lastSelectionAnchorId()
|
||||
if (!anchorId) {
|
||||
handleToggleTimelineSelection(id)
|
||||
return
|
||||
}
|
||||
|
||||
const segments = timelineSegments()
|
||||
const anchorIndex = segments.findIndex((s) => s.id === anchorId)
|
||||
const targetIndex = segments.findIndex((s) => s.id === id)
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
handleToggleTimelineSelection(id)
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, targetIndex)
|
||||
const end = Math.max(anchorIndex, targetIndex)
|
||||
|
||||
const rangeSegments = selectionMode() === "tools"
|
||||
? segments.slice(start, end + 1).filter((s) => s.type === "tool" && isMessageDeletable(s.messageId))
|
||||
: segments.slice(start, end + 1).filter((s) => isMessageDeletable(s.messageId))
|
||||
// Range selection replaces current selection so it can grow or shrink.
|
||||
setSelectedTimelineIds(new Set(rangeSegments.map((segment) => segment.id)))
|
||||
}
|
||||
|
||||
const handleClearTimelineSelection = () => {
|
||||
setSelectedTimelineIds(new Set<string>())
|
||||
setLastSelectionAnchorId(null)
|
||||
}
|
||||
|
||||
const applySelectionMode = (mode: "all" | "tools") => {
|
||||
setSelectionMode(mode)
|
||||
if (mode !== "tools") return
|
||||
const segments = timelineSegments()
|
||||
const toolIds = new Set(
|
||||
segments
|
||||
.filter((segment) => segment.type === "tool" && isMessageDeletable(segment.messageId))
|
||||
.map((segment) => segment.id),
|
||||
)
|
||||
setSelectedTimelineIds((prev) => {
|
||||
if (prev.size === 0) return prev
|
||||
const next = new Set([...prev].filter((id) => toolIds.has(id)))
|
||||
if (next.size === 0) setLastSelectionAnchorId(null)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
@@ -160,18 +380,102 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
}
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
|
||||
|
||||
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
|
||||
|
||||
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
|
||||
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
|
||||
const selectedDeleteCount = createMemo(() => selectedForDeletion().size)
|
||||
const selectedToolParts = createMemo(() => {
|
||||
const selected = selectedTimelineIds()
|
||||
if (selected.size === 0) return [] as { messageId: string; partId: string }[]
|
||||
const segments = timelineSegments()
|
||||
const segmentById = new Map<string, TimelineSegment>()
|
||||
for (const segment of segments) segmentById.set(segment.id, segment)
|
||||
const toolParts: { messageId: string; partId: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const segId of selected) {
|
||||
const segment = segmentById.get(segId)
|
||||
if (!segment || segment.type !== "tool") continue
|
||||
for (const partId of segment.toolPartIds ?? []) {
|
||||
if (!partId) continue
|
||||
const key = `${segment.messageId}:${partId}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
toolParts.push({ messageId: segment.messageId, partId })
|
||||
}
|
||||
}
|
||||
return toolParts
|
||||
})
|
||||
const deleteMessageIds = createMemo(() => selectedForDeletion())
|
||||
const deleteToolParts = createMemo(() => {
|
||||
const messageIds = deleteMessageIds()
|
||||
const allowed = deletableMessageIds()
|
||||
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
|
||||
})
|
||||
const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
|
||||
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
|
||||
|
||||
const selectedTokenTotal = createMemo(() => {
|
||||
const selected = deleteMessageIds()
|
||||
const toolParts = deleteToolParts()
|
||||
if (selected.size === 0 && toolParts.length === 0) return 0
|
||||
// Fresh-from-store chars: read parts directly via buildRecordDisplayData +
|
||||
// getPartCharCount so the toolbar stays consistent with the xray overlay
|
||||
// (which also reads live from the store). Falls back to segment totalChars
|
||||
// when no record is found (e.g. compaction segments).
|
||||
const s = store()
|
||||
let total = 0
|
||||
for (const messageId of selected) {
|
||||
let chars = 0
|
||||
const record = s.getMessage(messageId)
|
||||
if (record) {
|
||||
const displayData = buildRecordDisplayData(props.instanceId, record)
|
||||
for (const part of displayData.orderedParts) {
|
||||
chars += getPartCharCount(part)
|
||||
}
|
||||
} else {
|
||||
// Fallback: sum from segments (O(n) pre-pass scoped to this branch)
|
||||
for (const seg of timelineSegments()) {
|
||||
if (seg.messageId === messageId) chars += seg.totalChars
|
||||
}
|
||||
}
|
||||
total += Math.max(Math.round(chars / 4), 1)
|
||||
}
|
||||
if (toolParts.length > 0) {
|
||||
const partFallbackChars = new Map<string, number>()
|
||||
for (const segment of timelineSegments()) {
|
||||
if (segment.type !== "tool") continue
|
||||
for (const partId of segment.toolPartIds ?? []) {
|
||||
if (!partId || partFallbackChars.has(partId)) continue
|
||||
partFallbackChars.set(partId, segment.totalChars)
|
||||
}
|
||||
}
|
||||
for (const { messageId, partId } of toolParts) {
|
||||
let chars = 0
|
||||
const record = s.getMessage(messageId)
|
||||
const partRecord = record?.parts?.[partId]
|
||||
if (partRecord?.data) {
|
||||
chars = getPartCharCount(partRecord.data)
|
||||
} else {
|
||||
chars = partFallbackChars.get(partId) ?? 0
|
||||
}
|
||||
total += Math.max(Math.round(chars / 4), 1)
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const formatTokenCount = (tokens: number): string => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
|
||||
|
||||
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
|
||||
if (!messageId) return
|
||||
if (!isMessageDeletable(messageId)) return
|
||||
setSelectedForDeletion((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (selected) {
|
||||
@@ -186,21 +490,50 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const clearDeleteMode = () => {
|
||||
setSelectedForDeletion(new Set<string>())
|
||||
setDeleteHover({ kind: "none" })
|
||||
setSelectedTimelineIds(new Set<string>())
|
||||
setLastSelectionAnchorId(null)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const timelineIds = selectedTimelineIds()
|
||||
if (timelineIds.size === 0) {
|
||||
setSelectedForDeletion(new Set<string>())
|
||||
return
|
||||
}
|
||||
const segments = timelineSegments()
|
||||
const segmentById = new Map<string, TimelineSegment>()
|
||||
for (const segment of segments) segmentById.set(segment.id, segment)
|
||||
const affectedMessageIds = new Set<string>()
|
||||
for (const segId of timelineIds) {
|
||||
const segment = segmentById.get(segId)
|
||||
if (segment && segment.type !== "tool" && isMessageDeletable(segment.messageId)) {
|
||||
affectedMessageIds.add(segment.messageId)
|
||||
}
|
||||
}
|
||||
setSelectedForDeletion(affectedMessageIds)
|
||||
})
|
||||
|
||||
const selectAllForDeletion = () => {
|
||||
setSelectedForDeletion(new Set<string>(messageIds()))
|
||||
const allMessageIds = [...deletableMessageIds()]
|
||||
setSelectedForDeletion(new Set<string>(allMessageIds))
|
||||
// Also select all timeline segments — tool visibility is handled by
|
||||
// isSelectionActive() in isHidden(), no expand/collapse needed.
|
||||
const segments = timelineSegments()
|
||||
setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id)))
|
||||
}
|
||||
|
||||
const deleteSelectedMessages = async () => {
|
||||
const selected = selectedForDeletion()
|
||||
if (selected.size === 0) return
|
||||
const selected = deleteMessageIds()
|
||||
const toolParts = deleteToolParts()
|
||||
if (selected.size === 0 && toolParts.length === 0) return
|
||||
|
||||
const allowed = deletableMessageIds()
|
||||
|
||||
const idsInSessionOrder = messageIds()
|
||||
const toDelete: string[] = []
|
||||
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
|
||||
const id = idsInSessionOrder[idx]
|
||||
if (selected.has(id)) {
|
||||
if (allowed.has(id) && selected.has(id)) {
|
||||
toDelete.push(id)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +542,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
for (const messageId of toDelete) {
|
||||
await deleteMessage(props.instanceId, props.sessionId, messageId)
|
||||
}
|
||||
for (const { messageId, partId } of toolParts) {
|
||||
if (!allowed.has(messageId)) continue
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
|
||||
}
|
||||
clearDeleteMode()
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
||||
@@ -391,6 +728,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
handleClearTimelineSelection()
|
||||
previousTimelineIds = []
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
@@ -524,6 +862,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Prune stale selection IDs: segment IDs are positional and change on rebuild.
|
||||
setSelectedTimelineIds((prev) => {
|
||||
if (prev.size === 0) return prev
|
||||
const currentIds = new Set(timelineSegments().map((s) => s.id))
|
||||
const pruned = new Set([...prev].filter((id) => currentIds.has(id)))
|
||||
return pruned.size === prev.size ? prev : pruned
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -596,6 +942,29 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && (selectedTimelineIds().size > 0 || selectedForDeletion().size > 0)) {
|
||||
clearDeleteMode()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!isDeleteMenuOpen()) return
|
||||
if (typeof document === "undefined") return
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (deleteMenuRef?.contains(target)) return
|
||||
if (deleteMenuButtonRef?.contains(target)) return
|
||||
setIsDeleteMenuOpen(false)
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick)
|
||||
onCleanup(() => document.removeEventListener("mousedown", handleClick))
|
||||
})
|
||||
onCleanup(() => {
|
||||
clearPendingTimelinePartUpdateFrame()
|
||||
clearQuoteSelection()
|
||||
@@ -612,31 +981,37 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<VirtualFollowList
|
||||
items={messageIds}
|
||||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||
overscanPx={800}
|
||||
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
loading={() => Boolean(props.loading)}
|
||||
isActive={isActive}
|
||||
scrollToBottomOnActivate={() => false}
|
||||
initialScrollToBottom={() => false}
|
||||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
onScroll={() => {
|
||||
clearQuoteSelection()
|
||||
scrollCache.persist(streamElement())
|
||||
}}
|
||||
onMouseUp={() => handleStreamMouseUp()}
|
||||
onActiveKeyChange={setActiveMessageId}
|
||||
onScrollElementChange={(element) => {
|
||||
setStreamElement(element)
|
||||
if (!element) clearQuoteSelection()
|
||||
}}
|
||||
<VirtualFollowList
|
||||
items={messageIds}
|
||||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||
overscanPx={800}
|
||||
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
loading={() => Boolean(props.loading)}
|
||||
isActive={isActive}
|
||||
scrollToBottomOnActivate={() => false}
|
||||
initialScrollToBottom={() => false}
|
||||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
onScroll={() => {
|
||||
clearQuoteSelection()
|
||||
scrollCache.persist(streamElement())
|
||||
}}
|
||||
onMouseUp={() => handleStreamMouseUp()}
|
||||
onActiveKeyChange={(messageId) => {
|
||||
if (!messageId) return
|
||||
const firstSeg = timelineSegments().find((s) => s.messageId === messageId)
|
||||
if (firstSeg) {
|
||||
setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id))
|
||||
}
|
||||
}}
|
||||
onScrollElementChange={(element) => {
|
||||
setStreamElement(element)
|
||||
if (!element) clearQuoteSelection()
|
||||
}}
|
||||
onShellElementChange={(element) => {
|
||||
setStreamShellElement(element)
|
||||
if (!element) clearQuoteSelection()
|
||||
@@ -651,12 +1026,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img
|
||||
src={codeNomadLogo}
|
||||
alt={t("messageSection.empty.logoAlt")}
|
||||
class="h-48 w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
|
||||
</div>
|
||||
<h3>{t("messageSection.empty.title")}</h3>
|
||||
@@ -725,12 +1095,138 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Show when={isDeleteMode()}>
|
||||
<div
|
||||
class="message-delete-mode-toolbar"
|
||||
role="toolbar"
|
||||
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
|
||||
>
|
||||
<div class="message-delete-mode-toolbar-row" aria-hidden="true">
|
||||
<span class="message-delete-mode-token-group">
|
||||
<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>
|
||||
|
||||
<div class="message-delete-mode-menu-container">
|
||||
<button
|
||||
ref={(el) => {
|
||||
deleteMenuButtonRef = el
|
||||
}}
|
||||
type="button"
|
||||
class="message-delete-mode-button message-delete-mode-button--menu"
|
||||
onClick={() => setIsDeleteMenuOpen((prev) => !prev)}
|
||||
title={t("messageSection.bulkDelete.moreOptionsTitle")}
|
||||
aria-label={t("messageSection.bulkDelete.moreOptionsTitle")}
|
||||
>
|
||||
<MoreHorizontal class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<Show when={isDeleteMenuOpen()}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
deleteMenuRef = el
|
||||
}}
|
||||
class="message-delete-mode-menu dropdown-surface"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
onClick={() => {
|
||||
selectAllForDeletion()
|
||||
setIsDeleteMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
{t("messageSection.bulkDelete.selectAllTitle")}
|
||||
</button>
|
||||
<div class="message-delete-mode-menu-divider" aria-hidden="true" />
|
||||
<div class="message-delete-mode-menu-row">
|
||||
<span class="message-delete-mode-menu-label">{t("messageSection.bulkDelete.selectionModeLabel")}</span>
|
||||
<div class="message-delete-mode-menu-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="message-delete-mode-menu-toggle-button"
|
||||
data-mode="all"
|
||||
data-active={selectionMode() === "all"}
|
||||
onClick={() => applySelectionMode("all")}
|
||||
>
|
||||
{t("messageSection.bulkDelete.selectionModeAll")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="message-delete-mode-menu-toggle-button"
|
||||
data-mode="tools"
|
||||
data-active={selectionMode() === "tools"}
|
||||
onClick={() => applySelectionMode("tools")}
|
||||
>
|
||||
{t("messageSection.bulkDelete.selectionModeTools")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="message-delete-mode-hint-row keyboard-hints" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+click" />
|
||||
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.toggle")}</span>
|
||||
<span class="message-delete-mode-hint-sep">·</span>
|
||||
<Kbd shortcut="shift+click" />
|
||||
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.range")}</span>
|
||||
<span class="message-delete-mode-hint-sep">·</span>
|
||||
<Kbd shortcut="esc" />
|
||||
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.clear")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasTimelineSegments()}>
|
||||
<div class="message-timeline-sidebar">
|
||||
<MessageTimeline
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
activeMessageId={activeMessageId()}
|
||||
onToggleSelection={handleToggleTimelineSelection}
|
||||
onLongPressSelection={handleLongPressTimelineSelection}
|
||||
onSelectRange={handleSelectRangeTimeline}
|
||||
onClearSelection={handleClearTimelineSelection}
|
||||
selectedIds={selectedTimelineIds}
|
||||
expandedMessageIds={expandedMessageIds}
|
||||
deletableMessageIds={deletableMessageIds}
|
||||
activeSegmentId={activeSegmentId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showToolSegments={showTimelineToolsPreference()}
|
||||
@@ -742,48 +1238,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -22,12 +23,22 @@ export interface TimelineSegment {
|
||||
toolPartIds?: string[]
|
||||
partIds?: string[]
|
||||
partId?: string
|
||||
totalChars: number
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
activeMessageId?: string | null
|
||||
onToggleSelection?: (id: string) => void
|
||||
onLongPressSelection?: (segment: TimelineSegment) => void
|
||||
onSelectRange?: (id: string) => void
|
||||
onClearSelection?: () => void
|
||||
selectedIds?: Accessor<Set<string>>
|
||||
expandedMessageIds?: Accessor<Set<string>>
|
||||
// Optional: restrict histogram/xray overlay to only show for these message ids.
|
||||
// Used to hide ribs for messages before the last compaction.
|
||||
deletableMessageIds?: Accessor<Set<string>>
|
||||
activeSegmentId?: string | null
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showToolSegments?: boolean
|
||||
@@ -39,6 +50,9 @@ interface MessageTimelineProps {
|
||||
}
|
||||
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
const LONG_PRESS_MS = 500
|
||||
const JITTER_THRESHOLD = 10
|
||||
const ABSOLUTE_TOKEN_CAP = 10000
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -47,6 +61,7 @@ interface PendingSegment {
|
||||
texts: string[]
|
||||
reasoningTexts: string[]
|
||||
partIds: string[]
|
||||
totalChars: number
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
@@ -182,7 +197,7 @@ export function buildTimelineSegments(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||
)
|
||||
|
||||
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
@@ -191,11 +206,12 @@ export function buildTimelineSegments(
|
||||
tooltip,
|
||||
shortLabel,
|
||||
partIds: pending.partIds,
|
||||
totalChars: pending.totalChars,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
}
|
||||
|
||||
|
||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
@@ -204,6 +220,7 @@ export function buildTimelineSegments(
|
||||
texts: [],
|
||||
reasoningTexts: [],
|
||||
partIds: [],
|
||||
totalChars: 0,
|
||||
hasPrimaryText: type !== "assistant",
|
||||
}
|
||||
}
|
||||
@@ -229,6 +246,7 @@ export function buildTimelineSegments(
|
||||
tooltip: formatToolTooltip([title], t),
|
||||
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
|
||||
toolPartIds: partId ? [partId] : undefined,
|
||||
totalChars: getPartCharCount(part),
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
@@ -243,10 +261,11 @@ export function buildTimelineSegments(
|
||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||
target.partIds.push((part as any).id)
|
||||
}
|
||||
target.totalChars += getPartCharCount(part)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushPending()
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
@@ -259,6 +278,7 @@ export function buildTimelineSegments(
|
||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
partId,
|
||||
totalChars: 0,
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
@@ -267,7 +287,7 @@ export function buildTimelineSegments(
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
const text = collectTextFromPart(part, t)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
@@ -277,12 +297,13 @@ export function buildTimelineSegments(
|
||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||
target.partIds.push((part as any).id)
|
||||
}
|
||||
target.totalChars += getPartCharCount(part)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flushPending()
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -299,7 +320,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
let closeTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
|
||||
|
||||
|
||||
const isHistogramEligible = (segment: TimelineSegment): boolean => {
|
||||
const allowed = props.deletableMessageIds?.()
|
||||
if (!allowed) return true
|
||||
return allowed.has(segment.messageId)
|
||||
}
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
buttonRefs.set(segmentId, element)
|
||||
@@ -307,7 +334,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
buttonRefs.delete(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(hoverTimer)
|
||||
@@ -333,8 +360,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
setHoverAnchorRect(null)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
// Suppress previews when items are selected or during long-press
|
||||
if ((props.selectedIds?.().size ?? 0) > 0 || longPressTimer !== null) return
|
||||
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
@@ -349,7 +379,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const handleMouseLeave = () => {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const anchor = hoverAnchorRect()
|
||||
@@ -371,11 +401,235 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
clearCloseTimer()
|
||||
})
|
||||
|
||||
createEffect(on(() => props.activeMessageId, (activeId) => {
|
||||
// --- Selection & histogram rib state ---
|
||||
const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0)
|
||||
|
||||
// Segments eligible for xray ribs. We intentionally exclude messages before
|
||||
// the last compaction (when provided by the parent) to avoid misleading token
|
||||
// weights for content that's no longer in context.
|
||||
const xraySegments = createMemo(() => {
|
||||
if (!isSelectionActive()) return [] as TimelineSegment[]
|
||||
return props.segments.filter((segment) => isHistogramEligible(segment))
|
||||
})
|
||||
|
||||
// Stable layout offsets per badge (relative to scroll content), recomputed only
|
||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let xrayOverlayRef: HTMLDivElement | undefined
|
||||
|
||||
// Full layout recomputation: reads every badge's getBoundingClientRect once,
|
||||
// then stores offsets relative to the scroll content so they survive scrolling.
|
||||
const computeBadgeLayout = () => {
|
||||
if (!isSelectionActive() || !scrollContainerRef) return
|
||||
const containerRect = scrollContainerRef.getBoundingClientRect()
|
||||
const scrollTop = scrollContainerRef.scrollTop
|
||||
const offsets: Record<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)
|
||||
if (xrayOverlayRef) {
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
setWindowWidth(window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!isSelectionActive()) return
|
||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (isSelectionActive()) {
|
||||
computeBadgeLayout()
|
||||
if (typeof window !== "undefined") {
|
||||
// Deferred pass: tool segments become visible when selection activates,
|
||||
// but they may need a layout pass before getBoundingClientRect is accurate.
|
||||
requestAnimationFrame(computeBadgeLayout)
|
||||
window.addEventListener("resize", computeBadgeLayout)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", computeBadgeLayout)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Re-compute badge layout after expansion changes (tools become visible in DOM)
|
||||
createEffect(() => {
|
||||
props.expandedMessageIds?.()
|
||||
if (isSelectionActive()) {
|
||||
requestAnimationFrame(computeBadgeLayout)
|
||||
}
|
||||
})
|
||||
|
||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||
|
||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||
// tool parts whose output arrived after the timeline segment was first built.
|
||||
const liveSegmentChars = createMemo(() => {
|
||||
if (!isSelectionActive()) return {} as Record<string, number>
|
||||
const result: Record<string, number> = {}
|
||||
const resolvedStore = store()
|
||||
|
||||
// Compute live char counts by reading only the parts that the segment
|
||||
// references (partIds/toolPartIds). This stays accurate for streamed tool
|
||||
// outputs without scanning every part in the message.
|
||||
for (const segment of xraySegments()) {
|
||||
const record = resolvedStore.getMessage(segment.messageId)
|
||||
if (!record) {
|
||||
result[segment.id] = segment.totalChars
|
||||
continue
|
||||
}
|
||||
|
||||
const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])]
|
||||
let chars = 0
|
||||
for (const partId of ids) {
|
||||
const part = record.parts?.[partId]?.data
|
||||
if (!part) continue
|
||||
chars += getPartCharCount(part)
|
||||
}
|
||||
|
||||
result[segment.id] = chars > 0 ? chars : segment.totalChars
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup.
|
||||
// Avoids the previous O(n²) pattern of iterating all segments inside each <For> item.
|
||||
const aggregateTokensByMessageId = createMemo(() => {
|
||||
const chars = liveSegmentChars()
|
||||
const result: Record<string, number> = {}
|
||||
for (const s of xraySegments()) {
|
||||
result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars)
|
||||
}
|
||||
for (const id of Object.keys(result)) {
|
||||
result[id] = Math.max(Math.round(result[id] / 4), 1)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const getSegmentTokens = (segment: TimelineSegment): number => {
|
||||
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||
// When tools are hidden (not expanded, not in selection mode), assistant/user
|
||||
// bars show aggregate tokens for the whole message. When tools are visible
|
||||
// (expanded or selection mode active), each segment shows its own tokens to
|
||||
// avoid double-counting.
|
||||
if (!isExpanded && !isSelectionActive() && (segment.type === "assistant" || segment.type === "user")) {
|
||||
return aggregateTokensByMessageId()[segment.messageId] ?? 1
|
||||
}
|
||||
const chars = liveSegmentChars()[segment.id] ?? segment.totalChars
|
||||
return Math.max(Math.round(chars / 4), 1)
|
||||
}
|
||||
|
||||
const getMessageAggregateTokens = (messageId: string): number => {
|
||||
return aggregateTokensByMessageId()[messageId] ?? 1
|
||||
}
|
||||
|
||||
const formatTokenLabel = (tokens: number): string => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
const maxTokens = createMemo(() => {
|
||||
let max = 0
|
||||
for (const s of xraySegments()) {
|
||||
const tokens = getSegmentTokens(s)
|
||||
if (tokens > max) max = tokens
|
||||
}
|
||||
return Math.max(max, 1)
|
||||
})
|
||||
|
||||
// --- Long-press for mobile selection ---
|
||||
let longPressTimer: number | null = null
|
||||
let wasLongPress = false
|
||||
let pressStartPos = { x: 0, y: 0 }
|
||||
|
||||
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
wasLongPress = false
|
||||
pressStartPos = { x: event.clientX, y: event.clientY }
|
||||
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
|
||||
if (longPressTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(longPressTimer)
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
longPressTimer = window.setTimeout(() => {
|
||||
longPressTimer = null
|
||||
wasLongPress = true
|
||||
|
||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
if (props.onLongPressSelection) {
|
||||
props.onLongPressSelection(segment)
|
||||
} else {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (longPressTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (longPressTimer !== null) {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(event.clientX - pressStartPos.x, 2) +
|
||||
Math.pow(event.clientY - pressStartPos.y, 2),
|
||||
)
|
||||
if (dist > JITTER_THRESHOLD) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.clearTimeout(longPressTimer)
|
||||
}
|
||||
longPressTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (wasLongPress) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||
if (!activeId) return
|
||||
const targetSegment = untrack(() => props.segments).find((segment) => segment.messageId === activeId)
|
||||
if (!targetSegment) return
|
||||
const element = buttonRefs.get(targetSegment.id)
|
||||
const element = buttonRefs.get(activeId)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
@@ -402,127 +656,257 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
const previewData = createMemo(() => {
|
||||
|
||||
const segment = hoveredSegment()
|
||||
if (!segment) return null
|
||||
const record = store().getMessage(segment.messageId)
|
||||
if (!record) return null
|
||||
return { messageId: segment.messageId }
|
||||
})
|
||||
|
||||
|
||||
// Pre-computed set of messageIds that have at least one tool segment.
|
||||
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
|
||||
const messagesWithTools = createMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const s of props.segments) {
|
||||
if (s.type === "tool") set.add(s.messageId)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
// Pre-computed index map for session message ordering.
|
||||
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
|
||||
const messageIdToSessionIndex = createMemo(() => {
|
||||
const ids = store().getSessionMessageIds(props.sessionId)
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
|
||||
return map
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-timeline"
|
||||
role="navigation"
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
data-view="timeline"
|
||||
data-instance-id={props.instanceId}
|
||||
data-session-id={props.sessionId}
|
||||
>
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||
role="navigation"
|
||||
aria-label={t("messageTimeline.ariaLabel")}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<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 hover = deleteHover() as DeleteHoverState
|
||||
const selected = props.selectedMessageIds?.() ?? new Set<string>()
|
||||
if (selected.has(segment.messageId)) {
|
||||
return true
|
||||
}
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
if (targetIndex === undefined) return false
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
if (segmentIndex === undefined) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
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
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
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 = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
// Group visual indicators: tools belong to the same message as their
|
||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||
const groupRole = (): "child" | "parent" | "none" => {
|
||||
if (segment.type === "tool") return "child"
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) 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") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
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 (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""}`}
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""} ${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-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
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>
|
||||
)
|
||||
}}
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Capture scroll anchor before selection changes may toggle
|
||||
// tool segment visibility, which shifts timeline layout.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
// In selection mode, plain click scrolls to the message
|
||||
// instead of clearing. Selection is cleared by clicking
|
||||
// anywhere inside the chat container or pressing Esc.
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
props.onSegmentClick?.(segment)
|
||||
}
|
||||
|
||||
// Restore scroll anchor: keep the clicked badge at the same
|
||||
// visual position after hidden tools appear or disappear.
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||
scrollContainerRef.scrollTop = desired
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment, 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>
|
||||
|
||||
<Show when={isSelectionActive()}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
xrayOverlayRef = el
|
||||
if (xrayOverlayRef && scrollContainerRef) {
|
||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||
}
|
||||
}}
|
||||
class="message-timeline-xray-overlay"
|
||||
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
|
||||
>
|
||||
<div class="message-timeline-xray-overlay-inner">
|
||||
<For each={xraySegments()}>
|
||||
{(segment) => {
|
||||
const pos = () => {
|
||||
const offset = badgeOffsets()[segment.id]
|
||||
if (!offset) return null
|
||||
return { top: offset.layoutTop + offset.height / 2 }
|
||||
}
|
||||
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: "var(--xray-overhang)",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
Reference in New Issue
Block a user