Merge pull request #188 from VooDisss/issue-186
[QOL FEATURE]: implement 'Histogram Ribs' context x-ray for bulk selection (#186)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||||
import { CheckSquare, Trash, X } from "lucide-solid"
|
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||||
@@ -11,10 +11,11 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
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 { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
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,9 +80,224 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||||
|
if (selectionMode() === "tools" && segment.type !== "tool") {
|
||||||
|
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" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLastSelectionAnchorId(segment.id)
|
||||||
|
setActiveSegmentId(segment.id)
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||||
|
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [selectedTimelineIds, setSelectedTimelineIds] = createSignal<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 lastAssistantIndex = createMemo(() => {
|
||||||
@@ -149,18 +365,102 @@ 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" })
|
||||||
|
|
||||||
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
|
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
|
||||||
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
|
const selectedToolParts = createMemo(() => {
|
||||||
const selectedDeleteCount = createMemo(() => selectedForDeletion().size)
|
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 isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
|
||||||
|
|
||||||
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
|
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
|
||||||
if (!messageId) return
|
if (!messageId) return
|
||||||
|
if (!isMessageDeletable(messageId)) return
|
||||||
setSelectedForDeletion((prev) => {
|
setSelectedForDeletion((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -175,21 +475,50 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
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 deleteSelectedMessages = async () => {
|
||||||
const selected = selectedForDeletion()
|
const selected = deleteMessageIds()
|
||||||
if (selected.size === 0) return
|
const toolParts = deleteToolParts()
|
||||||
|
if (selected.size === 0 && toolParts.length === 0) return
|
||||||
|
|
||||||
|
const allowed = deletableMessageIds()
|
||||||
|
|
||||||
const idsInSessionOrder = messageIds()
|
const idsInSessionOrder = messageIds()
|
||||||
const toDelete: string[] = []
|
const toDelete: string[] = []
|
||||||
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
|
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
|
||||||
const id = idsInSessionOrder[idx]
|
const id = idsInSessionOrder[idx]
|
||||||
if (selected.has(id)) {
|
if (allowed.has(id) && selected.has(id)) {
|
||||||
toDelete.push(id)
|
toDelete.push(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +527,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
for (const messageId of toDelete) {
|
for (const messageId of toDelete) {
|
||||||
await deleteMessage(props.instanceId, props.sessionId, messageId)
|
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()
|
clearDeleteMode()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
||||||
@@ -565,6 +898,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()
|
||||||
@@ -698,6 +1032,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
return next
|
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +1111,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
|
||||||
@@ -789,6 +1142,19 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
hasRestoredScroll = true
|
hasRestoredScroll = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
|
||||||
let previousToken: string | undefined
|
let previousToken: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const token = changeToken()
|
const token = changeToken()
|
||||||
@@ -873,7 +1239,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 },
|
||||||
@@ -919,7 +1288,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
data-scroll-buttons={scrollButtonsCount()}
|
data-scroll-buttons={scrollButtonsCount()}
|
||||||
>
|
>
|
||||||
<div class="message-stream-shell" ref={setShellElement}>
|
<div class="message-stream-shell" ref={setShellElement}>
|
||||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp} onClick={(e) => {
|
||||||
|
// Clicking anywhere inside the chat container clears selection mode.
|
||||||
|
// Only fires when selection is active and the click target is not an
|
||||||
|
// interactive element inside a message block (buttons, links, etc.).
|
||||||
|
if (selectedTimelineIds().size === 0) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest("button, a, input, [role='button']")) return
|
||||||
|
handleClearTimelineSelection()
|
||||||
|
}}>
|
||||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
<Show when={!props.loading && messageIds().length === 0}>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -1017,6 +1394,121 @@ 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() })}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasTimelineSegments()}>
|
<Show when={hasTimelineSegments()}>
|
||||||
@@ -1024,7 +1516,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<MessageTimeline
|
<MessageTimeline
|
||||||
segments={timelineSegments()}
|
segments={timelineSegments()}
|
||||||
onSegmentClick={handleTimelineSegmentClick}
|
onSegmentClick={handleTimelineSegmentClick}
|
||||||
activeMessageId={activeMessageId()}
|
onToggleSelection={handleToggleTimelineSelection}
|
||||||
|
onLongPressSelection={handleLongPressTimelineSelection}
|
||||||
|
onSelectRange={handleSelectRangeTimeline}
|
||||||
|
onClearSelection={handleClearTimelineSelection}
|
||||||
|
selectedIds={selectedTimelineIds}
|
||||||
|
expandedMessageIds={expandedMessageIds}
|
||||||
|
deletableMessageIds={deletableMessageIds}
|
||||||
|
activeSegmentId={activeSegmentId()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
showToolSegments={showTimelineToolsPreference()}
|
showToolSegments={showTimelineToolsPreference()}
|
||||||
@@ -1036,48 +1535,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,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 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"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -22,12 +23,22 @@ 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
|
||||||
|
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
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
showToolSegments?: boolean
|
showToolSegments?: boolean
|
||||||
@@ -39,6 +50,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 +61,7 @@ interface PendingSegment {
|
|||||||
texts: string[]
|
texts: string[]
|
||||||
reasoningTexts: string[]
|
reasoningTexts: string[]
|
||||||
partIds: string[]
|
partIds: string[]
|
||||||
|
totalChars: number
|
||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +206,7 @@ export function buildTimelineSegments(
|
|||||||
tooltip,
|
tooltip,
|
||||||
shortLabel,
|
shortLabel,
|
||||||
partIds: pending.partIds,
|
partIds: pending.partIds,
|
||||||
|
totalChars: pending.totalChars,
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
pending = null
|
pending = null
|
||||||
@@ -204,6 +220,7 @@ export function buildTimelineSegments(
|
|||||||
texts: [],
|
texts: [],
|
||||||
reasoningTexts: [],
|
reasoningTexts: [],
|
||||||
partIds: [],
|
partIds: [],
|
||||||
|
totalChars: 0,
|
||||||
hasPrimaryText: type !== "assistant",
|
hasPrimaryText: type !== "assistant",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +246,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,6 +261,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -259,6 +278,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
|
||||||
@@ -277,6 +297,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +321,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
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 isHistogramEligible = (segment: TimelineSegment): boolean => {
|
||||||
|
const allowed = props.deletableMessageIds?.()
|
||||||
|
if (!allowed) return true
|
||||||
|
return allowed.has(segment.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -335,6 +362,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -371,11 +401,235 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
clearCloseTimer()
|
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
|
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,7 +656,6 @@ 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)
|
||||||
@@ -410,29 +663,52 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return { messageId: segment.messageId }
|
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 (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
<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}>
|
<For each={props.segments}>
|
||||||
{(segment) => {
|
{(segment, segIndex) => {
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
const isActive = () => props.activeMessageId === segment.messageId
|
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 (selected.has(segment.messageId)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (hover.kind === "message") {
|
if (hover.kind === "message") {
|
||||||
return hover.messageId === segment.messageId
|
return hover.messageId === segment.messageId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hover.kind === "deleteUpTo") {
|
if (hover.kind === "deleteUpTo") {
|
||||||
const ids = store().getSessionMessageIds(props.sessionId)
|
const indexMap = messageIdToSessionIndex()
|
||||||
const targetIndex = ids.indexOf(hover.messageId)
|
const targetIndex = indexMap.get(hover.messageId)
|
||||||
if (targetIndex === -1) return false
|
if (targetIndex === undefined) return false
|
||||||
const segmentIndex = ids.indexOf(segment.messageId)
|
const segmentIndex = indexMap.get(segment.messageId)
|
||||||
if (segmentIndex === -1) return false
|
if (segmentIndex === undefined) return false
|
||||||
return segmentIndex >= targetIndex
|
return segmentIndex >= targetIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +726,24 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission() || isDeleteHovered())
|
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||||
|
const isHidden = () => segment.type === "tool" && !(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered())
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
const shortLabelContent = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
@@ -473,13 +766,55 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
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) => {
|
||||||
|
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)}
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
@@ -515,6 +850,62 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -85,12 +83,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
|
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "Selected messages ({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "Selected items ({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected messages",
|
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected items",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
|
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "More options",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Selection",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "All",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Tools only",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Select item",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Select range",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Clear Selection",
|
||||||
"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 items",
|
||||||
"messageItem.status.queued": "QUEUED",
|
"messageItem.status.queued": "QUEUED",
|
||||||
"messageItem.status.generating": "Generating...",
|
"messageItem.status.generating": "Generating...",
|
||||||
"messageItem.status.sending": "Sending...",
|
"messageItem.status.sending": "Sending...",
|
||||||
|
|||||||
@@ -85,12 +85,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
|
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "Mensajes seleccionados ({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "Elementos seleccionados ({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar mensajes seleccionados",
|
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar elementos seleccionados",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
|
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Más opciones",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Selección",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Todo",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Solo herramientas",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Seleccionar elemento",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Seleccionar rango",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Borrar selección",
|
||||||
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
||||||
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
||||||
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
|
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los elementos seleccionados",
|
||||||
"messageItem.status.queued": "EN COLA",
|
"messageItem.status.queued": "EN COLA",
|
||||||
"messageItem.status.generating": "Generando...",
|
"messageItem.status.generating": "Generando...",
|
||||||
"messageItem.status.sending": "Enviando...",
|
"messageItem.status.sending": "Enviando...",
|
||||||
|
|||||||
@@ -85,12 +85,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
|
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "Messages sélectionnés ({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "Éléments sélectionnés ({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les messages sélectionnés",
|
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les éléments sélectionnés",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
|
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Plus d'options",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Sélection",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Tous",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Outils uniquement",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Selectionner un element",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Selectionner une plage",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Effacer la selection",
|
||||||
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
||||||
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
||||||
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
|
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les éléments sélectionnés",
|
||||||
"messageItem.status.queued": "EN FILE",
|
"messageItem.status.queued": "EN FILE",
|
||||||
"messageItem.status.generating": "Génération...",
|
"messageItem.status.generating": "Génération...",
|
||||||
"messageItem.status.sending": "Envoi...",
|
"messageItem.status.sending": "Envoi...",
|
||||||
|
|||||||
@@ -85,12 +85,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
|
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "選択したメッセージ({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "選択した項目({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "選択したメッセージを削除",
|
"messageSection.bulkDelete.deleteSelectedTitle": "選択した項目を削除",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
|
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "その他のオプション",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "選択",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "すべて",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "ツールのみ",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "項目を選択",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "範囲を選択",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "選択を解除",
|
||||||
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
||||||
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
||||||
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
|
"messageSection.bulkDelete.failedMessage": "選択した項目の削除に失敗しました",
|
||||||
"messageItem.status.queued": "待機中",
|
"messageItem.status.queued": "待機中",
|
||||||
"messageItem.status.generating": "生成中...",
|
"messageItem.status.generating": "生成中...",
|
||||||
"messageItem.status.sending": "送信中...",
|
"messageItem.status.sending": "送信中...",
|
||||||
|
|||||||
@@ -85,12 +85,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления",
|
"messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные сообщения ({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные элементы ({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные сообщения",
|
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные элементы",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
|
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Больше настроек",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Выбор",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Все",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Только инструменты",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Выбрать элемент",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Выбрать диапазон",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Очистить выбор",
|
||||||
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
||||||
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
||||||
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
|
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные элементы",
|
||||||
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
||||||
"messageItem.status.generating": "Генерация…",
|
"messageItem.status.generating": "Генерация…",
|
||||||
"messageItem.status.sending": "Отправка…",
|
"messageItem.status.sending": "Отправка…",
|
||||||
|
|||||||
@@ -85,12 +85,19 @@ export const messagingMessages = {
|
|||||||
|
|
||||||
"messageItem.selection.checkboxAriaLabel": "选择要删除的消息",
|
"messageItem.selection.checkboxAriaLabel": "选择要删除的消息",
|
||||||
|
|
||||||
"messageSection.bulkDelete.toolbarAriaLabel": "已选择的消息({count})",
|
"messageSection.bulkDelete.toolbarAriaLabel": "已选择的项目({count})",
|
||||||
"messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的消息",
|
"messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的项目",
|
||||||
"messageSection.bulkDelete.selectAllTitle": "全选消息",
|
"messageSection.bulkDelete.selectAllTitle": "全选消息",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "更多选项",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "选择",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "全部",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "仅工具",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "选择项目",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "选择范围",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "清除选择",
|
||||||
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
||||||
"messageSection.bulkDelete.failedTitle": "删除失败",
|
"messageSection.bulkDelete.failedTitle": "删除失败",
|
||||||
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
|
"messageSection.bulkDelete.failedMessage": "无法删除已选择的项目",
|
||||||
"messageItem.status.queued": "排队中",
|
"messageItem.status.queued": "排队中",
|
||||||
"messageItem.status.generating": "正在生成...",
|
"messageItem.status.generating": "正在生成...",
|
||||||
"messageItem.status.sending": "正在发送...",
|
"messageItem.status.sending": "正在发送...",
|
||||||
|
|||||||
66
packages/ui/src/lib/token-utils.ts
Normal file
66
packages/ui/src/lib/token-utils.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ClientPart } from "../types/message"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the total character content of a message part.
|
||||||
|
*
|
||||||
|
* Used by both the xray histogram overlay (message-timeline) and the
|
||||||
|
* bulk-delete toolbar token pills (message-section) so both surfaces
|
||||||
|
* derive token estimates from the same logic.
|
||||||
|
*
|
||||||
|
* Note: For tool parts we intentionally only count `state.input` and
|
||||||
|
* `state.output`. We exclude `state.metadata` from token estimation since
|
||||||
|
* metadata can contain large or verbose diagnostic payloads that are not
|
||||||
|
* representative of model context.
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
// Tool calls may be compacted server-side. When that happens we treat the
|
||||||
|
// tool payload as effectively absent from context for token estimation.
|
||||||
|
const compacted = (state as any)?.time?.compacted
|
||||||
|
if (compacted !== undefined && compacted !== null) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
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 (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
|
||||||
|
}
|
||||||
@@ -218,6 +218,9 @@ export interface InstanceMessageStore {
|
|||||||
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||||
getSessionRevision: (sessionId: string) => number
|
getSessionRevision: (sessionId: string) => number
|
||||||
getSessionMessageIds: (sessionId: string) => string[]
|
getSessionMessageIds: (sessionId: string) => string[]
|
||||||
|
// Index of the most recent message in the session that contains a compaction part.
|
||||||
|
// Returns -1 if there has been no compaction.
|
||||||
|
getLastCompactionMessageIndex: (sessionId: string) => number
|
||||||
getMessage: (messageId: string) => MessageRecord | undefined
|
getMessage: (messageId: string) => MessageRecord | undefined
|
||||||
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
|
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
|
||||||
clearSession: (sessionId: string) => void
|
clearSession: (sessionId: string) => void
|
||||||
@@ -231,6 +234,24 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
|
|
||||||
const messageInfoCache = new Map<string, MessageInfo>()
|
const messageInfoCache = new Map<string, MessageInfo>()
|
||||||
|
|
||||||
|
function getLastCompactionMessageIndex(sessionId: string): number {
|
||||||
|
if (!sessionId) return -1
|
||||||
|
const ids = state.sessions[sessionId]?.messageIds ?? []
|
||||||
|
// Scan from the end: we only care about the most recent compaction.
|
||||||
|
for (let i = ids.length - 1; i >= 0; i--) {
|
||||||
|
const messageId = ids[i]
|
||||||
|
const record = state.messages[messageId]
|
||||||
|
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) continue
|
||||||
|
for (const partId of record.partIds) {
|
||||||
|
const part = record.parts[partId]?.data
|
||||||
|
if ((part as any)?.type === "compaction") {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
|
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
|
||||||
if (!part || (part as any).type !== "tool") {
|
if (!part || (part as any).type !== "tool") {
|
||||||
return false
|
return false
|
||||||
@@ -1173,6 +1194,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
getScrollSnapshot,
|
getScrollSnapshot,
|
||||||
getSessionRevision: getSessionRevisionValue,
|
getSessionRevision: getSessionRevisionValue,
|
||||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||||
|
getLastCompactionMessageIndex,
|
||||||
getMessage: (messageId: string) => state.messages[messageId],
|
getMessage: (messageId: string) => state.messages[messageId],
|
||||||
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||||
clearSession,
|
clearSession,
|
||||||
|
|||||||
@@ -11,37 +11,35 @@
|
|||||||
|
|
||||||
.message-delete-mode-toolbar {
|
.message-delete-mode-toolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
left: 50%;
|
||||||
bottom: 12px;
|
transform: translateX(-50%);
|
||||||
display: flex;
|
bottom: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
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));
|
/* Match other popups (dropdown-surface / panels) */
|
||||||
|
background-color: var(--surface-base);
|
||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
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: var(--panel-shadow-strong);
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(80vw, 560px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Avoid covering the scroll-to-top/bottom floating buttons. */
|
.message-delete-mode-toolbar-row {
|
||||||
.message-layout[data-scroll-buttons="1"] .message-delete-mode-toolbar {
|
display: flex;
|
||||||
bottom: 4.25rem;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-layout[data-scroll-buttons="2"] .message-delete-mode-toolbar {
|
.message-delete-mode-token-group {
|
||||||
bottom: 7.5rem;
|
display: inline-flex;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
/* 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 +50,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 +91,142 @@
|
|||||||
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--menu {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-button--menu:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu {
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
min-width: 150px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(70vw, 220px);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 3px 0;
|
||||||
|
background-color: var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-toggle-button {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-toggle-button[data-mode="all"] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-toggle-button[data-mode="tools"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu-toggle-button[data-active="true"] {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: color-mix(in oklab, var(--accent-primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-menu .dropdown-item {
|
||||||
|
width: calc(100% - 8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.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-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
user-select: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-hint-text {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-delete-mode-hint-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
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,146 @@
|
|||||||
.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: absolute;
|
||||||
|
inset: 0;
|
||||||
|
/* Extend the overlay box into the stream so ribs are not relying on
|
||||||
|
overflow-visible behavior (which is brittle around scroll containers). */
|
||||||
|
--xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
|
||||||
|
left: calc(-1 * var(--xray-overhang));
|
||||||
|
width: calc(100% + var(--xray-overhang));
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.25rem;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Above the scroll container background; still non-interactive. */
|
||||||
|
z-index: 2;
|
||||||
|
--xray-scroll-y: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-xray-overlay-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateY(var(--xray-scroll-y));
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-xray-rib {
|
||||||
|
position: absolute;
|
||||||
|
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: var(--text-primary);
|
||||||
|
background: var(--surface-base);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
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