Disable follow-mode virtualization churn and simplify reasoning header layout so streaming thinking blocks stop nudging the scroll position while the list is pinned to bottom.
1259 lines
47 KiB
TypeScript
1259 lines
47 KiB
TypeScript
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
|
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
|
import Kbd from "./kbd"
|
|
import MessageBlock from "./message-block"
|
|
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
|
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
|
import VirtualFollowList, { type VirtualFollowListApi, type VirtualFollowListState } from "./virtual-follow-list"
|
|
import { useConfig } from "../stores/preferences"
|
|
import { getSessionInfo } from "../stores/sessions"
|
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
|
import { useI18n } from "../lib/i18n"
|
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
|
import { copyToClipboard } from "../lib/clipboard"
|
|
import { showToastNotification } from "../lib/notifications"
|
|
import { showAlertDialog } from "../stores/alerts"
|
|
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
|
import type { DeleteHoverState } from "../types/delete-hover"
|
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
|
import { getPartCharCount } from "../lib/token-utils"
|
|
|
|
const SCROLL_SENTINEL_MARGIN_PX = 48
|
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
|
|
|
export interface MessageSectionProps {
|
|
instanceId: string
|
|
sessionId: string
|
|
loading?: boolean
|
|
onRevert?: (messageId: string) => void
|
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
onFork?: (messageId?: string) => void
|
|
registerScrollToBottom?: (fn: () => void) => void
|
|
showSidebarToggle?: boolean
|
|
onSidebarToggle?: () => void
|
|
forceCompactStatusLayout?: boolean
|
|
onQuoteSelection?: (text: string, mode: "quote" | "code") => void
|
|
isActive?: boolean
|
|
}
|
|
|
|
export default function MessageSection(props: MessageSectionProps) {
|
|
const { preferences } = useConfig()
|
|
const { t } = useI18n()
|
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
|
|
|
const scrollCache = useScrollCache({
|
|
instanceId: props.instanceId,
|
|
sessionId: props.sessionId,
|
|
scope: MESSAGE_SCROLL_CACHE_SCOPE,
|
|
})
|
|
|
|
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
|
const sessionInfo = createMemo(() =>
|
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
|
cost: 0,
|
|
contextWindow: 0,
|
|
isSubscriptionModel: false,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningTokens: 0,
|
|
actualUsageTokens: 0,
|
|
modelOutputLimit: 0,
|
|
contextAvailableTokens: null,
|
|
},
|
|
)
|
|
|
|
const tokenStats = createMemo(() => {
|
|
const usage = usageSnapshot()
|
|
const info = sessionInfo()
|
|
return {
|
|
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
|
avail: info.contextAvailableTokens,
|
|
}
|
|
})
|
|
|
|
const preferenceSignature = createMemo(() => {
|
|
const pref = preferences()
|
|
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
|
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
|
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
|
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
|
})
|
|
|
|
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
|
const scrollToMessage = () => {
|
|
const api = listApi()
|
|
if (api) {
|
|
api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" })
|
|
return
|
|
}
|
|
if (typeof document === "undefined") return
|
|
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
|
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
|
}
|
|
|
|
if (selectionMode() === "tools" && segment.type !== "tool") {
|
|
setActiveSegmentId(segment.id)
|
|
scrollToMessage()
|
|
return
|
|
}
|
|
|
|
setLastSelectionAnchorId(segment.id)
|
|
setActiveSegmentId(segment.id)
|
|
scrollToMessage()
|
|
}
|
|
|
|
const [selectedTimelineIds, setSelectedTimelineIds] = createSignal<Set<string>>(new Set())
|
|
const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal<string | null>(null)
|
|
const [expandedMessageIds, setExpandedMessageIds] = createSignal<Set<string>>(new Set())
|
|
const [selectionMode, setSelectionMode] = createSignal<"all" | "tools">("all")
|
|
const [isDeleteMenuOpen, setIsDeleteMenuOpen] = createSignal(false)
|
|
let deleteMenuRef: HTMLDivElement | undefined
|
|
let deleteMenuButtonRef: HTMLButtonElement | undefined
|
|
|
|
// Deletion is only allowed for messages/tool parts that occur AFTER the most
|
|
// recent compaction. Compaction effectively resets the stored context; deleting
|
|
// earlier items would not reliably reflect what the model sees.
|
|
const messageIndexById = createMemo(() => {
|
|
const ids = messageIds()
|
|
const map = new Map<string, number>()
|
|
for (let i = 0; i < ids.length; i++) {
|
|
map.set(ids[i], i)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const lastCompactionIndex = createMemo(() => {
|
|
// Depend on a single session revision signal (not every message/part read)
|
|
// to keep reactive overhead small.
|
|
sessionRevision()
|
|
return untrack(() => store().getLastCompactionMessageIndex(props.sessionId))
|
|
})
|
|
|
|
const deletableStartIndex = createMemo(() => {
|
|
const idx = lastCompactionIndex()
|
|
return idx === -1 ? 0 : idx + 1
|
|
})
|
|
|
|
const deletableMessageIds = createMemo(() => {
|
|
const ids = messageIds()
|
|
const start = deletableStartIndex()
|
|
return new Set(ids.slice(start))
|
|
})
|
|
|
|
const isMessageDeletable = (messageId: string): boolean => {
|
|
const idx = messageIndexById().get(messageId)
|
|
if (idx === undefined) return false
|
|
return idx >= deletableStartIndex()
|
|
}
|
|
|
|
// Build the message group for a segment.
|
|
// Tool calls belong to the same assistant turn (between user messages).
|
|
// Only assistant badges trigger group selection; user/tool badges are standalone.
|
|
const getAdjacentGroup = (_clickedIndex: number, segments: TimelineSegment[]): TimelineSegment[] => {
|
|
const clicked = segments[_clickedIndex]
|
|
if (clicked.type === "assistant") {
|
|
let currentTurn = -1
|
|
const turnByMessageId = new Map<string, number>()
|
|
for (const segment of segments) {
|
|
if (segment.type === "user") {
|
|
currentTurn += 1
|
|
continue
|
|
}
|
|
if (currentTurn === -1) currentTurn = 0
|
|
if (!turnByMessageId.has(segment.messageId)) {
|
|
turnByMessageId.set(segment.messageId, currentTurn)
|
|
}
|
|
}
|
|
const turnIndex = turnByMessageId.get(clicked.messageId)
|
|
if (turnIndex === undefined) {
|
|
return segments.filter((s) => s.messageId === clicked.messageId)
|
|
}
|
|
return segments.filter((s) => s.type !== "user" && turnByMessageId.get(s.messageId) === turnIndex)
|
|
}
|
|
// User, tool, and compaction segments are standalone.
|
|
return [clicked]
|
|
}
|
|
|
|
const handleToggleTimelineSelection = (id: string) => {
|
|
const segments = timelineSegments()
|
|
const segmentIndex = segments.findIndex((s) => s.id === id)
|
|
if (segmentIndex === -1) return
|
|
const segment = segments[segmentIndex]
|
|
|
|
if (!isMessageDeletable(segment.messageId)) {
|
|
return
|
|
}
|
|
|
|
setLastSelectionAnchorId(id)
|
|
|
|
if (selectionMode() === "tools" && segment.type !== "tool") {
|
|
return
|
|
}
|
|
|
|
const selected = selectedTimelineIds()
|
|
const isCurrentlySelected = selected.has(id)
|
|
const group = getAdjacentGroup(segmentIndex, segments)
|
|
const hasToolsInGroup = group.some((s) => s.type === "tool")
|
|
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
|
|
const selectedInGroup = isGroupCandidate
|
|
? group.reduce((count, s) => (selected.has(s.id) ? count + 1 : count), 0)
|
|
: 0
|
|
const isGroupEmpty = isGroupCandidate && selectedInGroup === 0
|
|
|
|
if (isGroupCandidate && !isCurrentlySelected && isGroupEmpty) {
|
|
// Parent click: select entire group only when none are selected yet.
|
|
// Tool visibility is handled by isSelectionActive() in isHidden() — no
|
|
// expand/collapse needed.
|
|
setSelectedTimelineIds((prev) => {
|
|
const next = new Set(prev)
|
|
for (const s of group) next.add(s.id)
|
|
return next
|
|
})
|
|
} else if (isCurrentlySelected) {
|
|
// Individual deselect (tool or parent). No group deselect.
|
|
const newSelected = new Set(selected)
|
|
newSelected.delete(id)
|
|
setSelectedTimelineIds(newSelected)
|
|
} else {
|
|
// Individual select (tool badge, parent with partial group, or standalone).
|
|
setSelectedTimelineIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleLongPressTimelineSelection = (segment: TimelineSegment) => {
|
|
const segments = timelineSegments()
|
|
const segmentIndex = segments.findIndex((s) => s.id === segment.id)
|
|
if (segmentIndex === -1) return
|
|
|
|
if (!isMessageDeletable(segment.messageId)) {
|
|
return
|
|
}
|
|
|
|
setLastSelectionAnchorId(segment.id)
|
|
|
|
if (selectionMode() === "tools" && segment.type !== "tool") {
|
|
return
|
|
}
|
|
const group = getAdjacentGroup(segmentIndex, segments)
|
|
const hasToolsInGroup = group.some((s) => s.type === "tool")
|
|
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
|
|
if (!isGroupCandidate) {
|
|
handleToggleTimelineSelection(segment.id)
|
|
return
|
|
}
|
|
const selected = selectedTimelineIds()
|
|
const hasAnySelected = group.some((s) => selected.has(s.id))
|
|
if (!hasAnySelected) {
|
|
setSelectedTimelineIds((prev) => {
|
|
const next = new Set(prev)
|
|
for (const s of group) next.add(s.id)
|
|
return next
|
|
})
|
|
return
|
|
}
|
|
const newSelected = new Set(selected)
|
|
for (const s of group) newSelected.delete(s.id)
|
|
setSelectedTimelineIds(newSelected)
|
|
}
|
|
|
|
const handleSelectRangeTimeline = (id: string) => {
|
|
const anchorId = lastSelectionAnchorId()
|
|
if (!anchorId) {
|
|
handleToggleTimelineSelection(id)
|
|
return
|
|
}
|
|
|
|
const segments = timelineSegments()
|
|
const anchorIndex = segments.findIndex((s) => s.id === anchorId)
|
|
const targetIndex = segments.findIndex((s) => s.id === id)
|
|
|
|
if (anchorIndex === -1 || targetIndex === -1) {
|
|
handleToggleTimelineSelection(id)
|
|
return
|
|
}
|
|
|
|
const start = Math.min(anchorIndex, targetIndex)
|
|
const end = Math.max(anchorIndex, targetIndex)
|
|
|
|
const rangeSegments = selectionMode() === "tools"
|
|
? segments.slice(start, end + 1).filter((s) => s.type === "tool" && isMessageDeletable(s.messageId))
|
|
: segments.slice(start, end + 1).filter((s) => isMessageDeletable(s.messageId))
|
|
// Range selection replaces current selection so it can grow or shrink.
|
|
setSelectedTimelineIds(new Set(rangeSegments.map((segment) => segment.id)))
|
|
}
|
|
|
|
const handleClearTimelineSelection = () => {
|
|
clearDeleteMode()
|
|
}
|
|
|
|
const applySelectionMode = (mode: "all" | "tools") => {
|
|
setSelectionMode(mode)
|
|
if (mode !== "tools") return
|
|
const segments = timelineSegments()
|
|
const toolIds = new Set(
|
|
segments
|
|
.filter((segment) => segment.type === "tool" && isMessageDeletable(segment.messageId))
|
|
.map((segment) => segment.id),
|
|
)
|
|
setSelectedTimelineIds((prev) => {
|
|
if (prev.size === 0) return prev
|
|
const next = new Set([...prev].filter((id) => toolIds.has(id)))
|
|
if (next.size === 0) setLastSelectionAnchorId(null)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const lastAssistantIndex = createMemo(() => {
|
|
const ids = messageIds()
|
|
const resolvedStore = store()
|
|
for (let index = ids.length - 1; index >= 0; index--) {
|
|
const record = resolvedStore.getMessage(ids[index])
|
|
if (record?.role === "assistant") {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
})
|
|
|
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
|
const hasTimelineSegments = () => timelineSegments().length > 0
|
|
|
|
const seenTimelineMessageIds = new Set<string>()
|
|
const seenTimelineSegmentKeys = new Set<string>()
|
|
const timelinePartCountsByMessageId = new Map<string, number>()
|
|
let pendingTimelineMessagePartUpdates = new Set<string>()
|
|
let pendingTimelinePartUpdateFrame: number | null = null
|
|
|
|
function makeTimelineKey(segment: TimelineSegment) {
|
|
return `${segment.messageId}:${segment.id}:${segment.type}`
|
|
}
|
|
|
|
function seedTimeline() {
|
|
seenTimelineMessageIds.clear()
|
|
seenTimelineSegmentKeys.clear()
|
|
timelinePartCountsByMessageId.clear()
|
|
const ids = untrack(messageIds)
|
|
const resolvedStore = untrack(store)
|
|
const segments: TimelineSegment[] = []
|
|
ids.forEach((messageId) => {
|
|
const record = resolvedStore.getMessage(messageId)
|
|
if (!record) return
|
|
seenTimelineMessageIds.add(messageId)
|
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
|
built.forEach((segment) => {
|
|
const key = makeTimelineKey(segment)
|
|
if (seenTimelineSegmentKeys.has(key)) return
|
|
seenTimelineSegmentKeys.add(key)
|
|
segments.push(segment)
|
|
})
|
|
})
|
|
setTimelineSegments(segments)
|
|
}
|
|
|
|
function appendTimelineForMessage(messageId: string) {
|
|
const record = untrack(() => store().getMessage(messageId))
|
|
if (!record) return
|
|
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
|
if (built.length === 0) return
|
|
const newSegments: TimelineSegment[] = []
|
|
built.forEach((segment) => {
|
|
const key = makeTimelineKey(segment)
|
|
if (seenTimelineSegmentKeys.has(key)) return
|
|
seenTimelineSegmentKeys.add(key)
|
|
newSegments.push(segment)
|
|
})
|
|
if (newSegments.length > 0) {
|
|
setTimelineSegments((prev) => [...prev, ...newSegments])
|
|
}
|
|
}
|
|
const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
|
|
|
|
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
|
|
|
|
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
|
|
const selectedToolParts = createMemo(() => {
|
|
const selected = selectedTimelineIds()
|
|
if (selected.size === 0) return [] as { messageId: string; partId: string }[]
|
|
const segments = timelineSegments()
|
|
const segmentById = new Map<string, TimelineSegment>()
|
|
for (const segment of segments) segmentById.set(segment.id, segment)
|
|
const toolParts: { messageId: string; partId: string }[] = []
|
|
const seen = new Set<string>()
|
|
for (const segId of selected) {
|
|
const segment = segmentById.get(segId)
|
|
if (!segment || segment.type !== "tool") continue
|
|
for (const partId of segment.toolPartIds ?? []) {
|
|
if (!partId) continue
|
|
const key = `${segment.messageId}:${partId}`
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
toolParts.push({ messageId: segment.messageId, partId })
|
|
}
|
|
}
|
|
return toolParts
|
|
})
|
|
const deleteMessageIds = createMemo(() => selectedForDeletion())
|
|
const deleteToolParts = createMemo(() => {
|
|
const messageIds = deleteMessageIds()
|
|
const allowed = deletableMessageIds()
|
|
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
|
|
})
|
|
|
|
const deleteToolPartKeys = createMemo(() => {
|
|
const set = new Set<string>()
|
|
for (const entry of deleteToolParts()) {
|
|
set.add(`${entry.messageId}:${entry.partId}`)
|
|
}
|
|
return set
|
|
})
|
|
const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
|
|
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
|
|
|
|
const selectedTokenTotal = createMemo(() => {
|
|
const selected = deleteMessageIds()
|
|
const toolParts = deleteToolParts()
|
|
if (selected.size === 0 && toolParts.length === 0) return 0
|
|
// Fresh-from-store chars: read parts directly via buildRecordDisplayData +
|
|
// getPartCharCount so the toolbar stays consistent with the xray overlay
|
|
// (which also reads live from the store). Falls back to segment totalChars
|
|
// when no record is found (e.g. compaction segments).
|
|
const s = store()
|
|
let total = 0
|
|
for (const messageId of selected) {
|
|
let chars = 0
|
|
const record = s.getMessage(messageId)
|
|
if (record) {
|
|
const displayData = buildRecordDisplayData(props.instanceId, record)
|
|
for (const part of displayData.orderedParts) {
|
|
chars += getPartCharCount(part)
|
|
}
|
|
} else {
|
|
// Fallback: sum from segments (O(n) pre-pass scoped to this branch)
|
|
for (const seg of timelineSegments()) {
|
|
if (seg.messageId === messageId) chars += seg.totalChars
|
|
}
|
|
}
|
|
total += Math.max(Math.round(chars / 4), 1)
|
|
}
|
|
if (toolParts.length > 0) {
|
|
const partFallbackChars = new Map<string, number>()
|
|
for (const segment of timelineSegments()) {
|
|
if (segment.type !== "tool") continue
|
|
for (const partId of segment.toolPartIds ?? []) {
|
|
if (!partId || partFallbackChars.has(partId)) continue
|
|
partFallbackChars.set(partId, segment.totalChars)
|
|
}
|
|
}
|
|
for (const { messageId, partId } of toolParts) {
|
|
let chars = 0
|
|
const record = s.getMessage(messageId)
|
|
const partRecord = record?.parts?.[partId]
|
|
if (partRecord?.data) {
|
|
chars = getPartCharCount(partRecord.data)
|
|
} else {
|
|
chars = partFallbackChars.get(partId) ?? 0
|
|
}
|
|
total += Math.max(Math.round(chars / 4), 1)
|
|
}
|
|
}
|
|
return total
|
|
})
|
|
|
|
const formatTokenCount = (tokens: number): string => {
|
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
|
return String(tokens)
|
|
}
|
|
|
|
const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
|
|
|
|
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
|
|
if (!messageId) return
|
|
if (!isMessageDeletable(messageId)) return
|
|
setSelectedForDeletion((prev) => {
|
|
const next = new Set(prev)
|
|
if (selected) {
|
|
next.add(messageId)
|
|
} else {
|
|
next.delete(messageId)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const clearDeleteMode = () => {
|
|
setSelectedForDeletion(new Set<string>())
|
|
setDeleteHover({ kind: "none" })
|
|
setSelectedTimelineIds(new Set<string>())
|
|
setLastSelectionAnchorId(null)
|
|
setIsDeleteMenuOpen(false)
|
|
}
|
|
|
|
createEffect(() => {
|
|
const timelineIds = selectedTimelineIds()
|
|
if (timelineIds.size === 0) {
|
|
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 allMessageIds = [...deletableMessageIds()]
|
|
setSelectedForDeletion(new Set<string>(allMessageIds))
|
|
// Also select all timeline segments — tool visibility is handled by
|
|
// isSelectionActive() in isHidden(), no expand/collapse needed.
|
|
const segments = timelineSegments()
|
|
setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id)))
|
|
}
|
|
|
|
const deleteSelectedMessages = async () => {
|
|
const selected = deleteMessageIds()
|
|
const toolParts = deleteToolParts()
|
|
if (selected.size === 0 && toolParts.length === 0) return
|
|
|
|
const allowed = deletableMessageIds()
|
|
|
|
const idsInSessionOrder = messageIds()
|
|
const toDelete: string[] = []
|
|
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
|
|
const id = idsInSessionOrder[idx]
|
|
if (allowed.has(id) && selected.has(id)) {
|
|
toDelete.push(id)
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const messageId of toDelete) {
|
|
await deleteMessage(props.instanceId, props.sessionId, messageId)
|
|
}
|
|
for (const { messageId, partId } of toolParts) {
|
|
if (!allowed.has(messageId)) continue
|
|
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
|
|
}
|
|
clearDeleteMode()
|
|
} catch (error) {
|
|
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
|
title: t("messageSection.bulkDelete.failedTitle"),
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
variant: "error",
|
|
})
|
|
}
|
|
}
|
|
|
|
const isActive = createMemo(() => props.isActive !== false)
|
|
const [listApi, setListApi] = createSignal<VirtualFollowListApi | null>(null)
|
|
const [listState, setListState] = createSignal<VirtualFollowListState | null>(null)
|
|
const scrollButtonsCount = createMemo(() => listState()?.scrollButtonsCount() ?? 0)
|
|
|
|
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
|
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
|
|
|
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
|
|
|
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
|
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
|
|
|
const [didRestoreScroll, setDidRestoreScroll] = createSignal(false)
|
|
createEffect(
|
|
on(
|
|
() => props.sessionId,
|
|
() => {
|
|
setDidRestoreScroll(false)
|
|
},
|
|
),
|
|
)
|
|
|
|
// Persist scroll position when switching sessions. This effect's cleanup runs
|
|
// when `props.sessionId` changes, before the next session is rendered.
|
|
createEffect(() => {
|
|
const sessionId = props.sessionId
|
|
onCleanup(() => {
|
|
const element = streamElement()
|
|
if (!element) return
|
|
const scrollTop = element.scrollTop
|
|
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
|
|
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, { scrollTop, atBottom })
|
|
})
|
|
})
|
|
|
|
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
|
|
|
createEffect(() => {
|
|
const api = listApi()
|
|
if (!api) return
|
|
if (props.registerScrollToBottom) {
|
|
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true }))
|
|
}
|
|
})
|
|
|
|
// Restore scroll position when the stream element is available.
|
|
createEffect(() => {
|
|
const element = streamElement()
|
|
const api = listApi()
|
|
if (!element || !api) return
|
|
if (props.loading) return
|
|
if (messageIds().length === 0) return
|
|
if (didRestoreScroll()) return
|
|
|
|
scrollCache.restore(element, {
|
|
behavior: "auto",
|
|
fallback: () => {
|
|
api.setAutoScroll(true)
|
|
api.scrollToBottom({ immediate: true })
|
|
},
|
|
onApplied: (snapshot) => {
|
|
// Keep follow mode consistent with the restored state.
|
|
api.setAutoScroll(snapshot?.atBottom ?? true)
|
|
setDidRestoreScroll(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
onCleanup(() => {
|
|
scrollCache.persist(streamElement())
|
|
})
|
|
|
|
function clearQuoteSelection() {
|
|
setQuoteSelection(null)
|
|
}
|
|
|
|
function isSelectionWithinStream(range: Range | null) {
|
|
const container = streamElement()
|
|
if (!range || !container) return false
|
|
const node = range.commonAncestorContainer
|
|
if (!node) return false
|
|
return container.contains(node)
|
|
}
|
|
|
|
function updateQuoteSelectionFromSelection() {
|
|
if (!props.onQuoteSelection || typeof window === "undefined") {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const selection = window.getSelection()
|
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const range = selection.getRangeAt(0)
|
|
if (!isSelectionWithinStream(range)) {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const shell = streamShellElement()
|
|
if (!shell) {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const rawText = selection.toString().trim()
|
|
if (!rawText) {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const limited =
|
|
rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText
|
|
if (!limited) {
|
|
clearQuoteSelection()
|
|
return
|
|
}
|
|
const rects = range.getClientRects()
|
|
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
|
const shellRect = shell.getBoundingClientRect()
|
|
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
|
// Keep the popover within the stream shell. The quote popover currently
|
|
// renders 3 actions; keep enough horizontal room for the pill.
|
|
const maxLeft = Math.max(shell.clientWidth - 260, 8)
|
|
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
|
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
|
}
|
|
|
|
function handleStreamMouseUp() {
|
|
updateQuoteSelectionFromSelection()
|
|
}
|
|
|
|
function handleQuoteSelectionRequest(mode: "quote" | "code") {
|
|
const info = quoteSelection()
|
|
if (!info || !props.onQuoteSelection) return
|
|
props.onQuoteSelection(info.text, mode)
|
|
clearQuoteSelection()
|
|
if (typeof window !== "undefined") {
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
}
|
|
}
|
|
|
|
async function handleCopySelectionRequest() {
|
|
const info = quoteSelection()
|
|
if (!info) return
|
|
|
|
const success = await copyToClipboard(info.text)
|
|
showToastNotification({
|
|
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
|
|
variant: success ? "success" : "error",
|
|
duration: success ? 2000 : 6000,
|
|
})
|
|
|
|
clearQuoteSelection()
|
|
if (typeof window !== "undefined") {
|
|
const selection = window.getSelection()
|
|
selection?.removeAllRanges()
|
|
}
|
|
}
|
|
|
|
function handleContentRendered() {
|
|
if (props.loading) return
|
|
listApi()?.notifyContentRendered()
|
|
}
|
|
|
|
let previousTimelineIds: string[] = []
|
|
|
|
createEffect(() => {
|
|
const loading = Boolean(props.loading)
|
|
const ids = messageIds()
|
|
|
|
if (loading) {
|
|
handleClearTimelineSelection()
|
|
previousTimelineIds = []
|
|
setTimelineSegments([])
|
|
seenTimelineMessageIds.clear()
|
|
seenTimelineSegmentKeys.clear()
|
|
timelinePartCountsByMessageId.clear()
|
|
pendingTimelineMessagePartUpdates.clear()
|
|
if (pendingTimelinePartUpdateFrame !== null) {
|
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
|
pendingTimelinePartUpdateFrame = null
|
|
}
|
|
return
|
|
}
|
|
|
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
|
seedTimeline()
|
|
previousTimelineIds = ids.slice()
|
|
return
|
|
}
|
|
|
|
if (ids.length < previousTimelineIds.length) {
|
|
seedTimeline()
|
|
previousTimelineIds = ids.slice()
|
|
return
|
|
}
|
|
|
|
if (ids.length === previousTimelineIds.length) {
|
|
let changedIndex = -1
|
|
let changeCount = 0
|
|
for (let index = 0; index < ids.length; index++) {
|
|
if (ids[index] !== previousTimelineIds[index]) {
|
|
changedIndex = index
|
|
changeCount += 1
|
|
if (changeCount > 1) break
|
|
}
|
|
}
|
|
if (changeCount === 1 && changedIndex >= 0) {
|
|
const oldId = previousTimelineIds[changedIndex]
|
|
const newId = ids[changedIndex]
|
|
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
|
seenTimelineMessageIds.delete(oldId)
|
|
seenTimelineMessageIds.add(newId)
|
|
setTimelineSegments((prev) => {
|
|
const next = prev.map((segment) => {
|
|
if (segment.messageId !== oldId) return segment
|
|
const updatedId = segment.id.replace(oldId, newId)
|
|
return { ...segment, messageId: newId, id: updatedId }
|
|
})
|
|
seenTimelineSegmentKeys.clear()
|
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
|
return next
|
|
})
|
|
|
|
// Keep part count tracking in sync with id replacement.
|
|
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
|
if (existingPartCount !== undefined) {
|
|
timelinePartCountsByMessageId.delete(oldId)
|
|
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
|
}
|
|
|
|
previousTimelineIds = ids.slice()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const newIds: string[] = []
|
|
ids.forEach((id) => {
|
|
if (!seenTimelineMessageIds.has(id)) {
|
|
newIds.push(id)
|
|
}
|
|
})
|
|
|
|
if (newIds.length > 0) {
|
|
newIds.forEach((id) => {
|
|
seenTimelineMessageIds.add(id)
|
|
appendTimelineForMessage(id)
|
|
})
|
|
}
|
|
|
|
previousTimelineIds = ids.slice()
|
|
})
|
|
|
|
function clearPendingTimelinePartUpdateFrame() {
|
|
if (pendingTimelinePartUpdateFrame !== null) {
|
|
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
|
pendingTimelinePartUpdateFrame = null
|
|
}
|
|
}
|
|
|
|
function scheduleTimelinePartUpdateFlush() {
|
|
if (pendingTimelinePartUpdateFrame !== null) return
|
|
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
|
pendingTimelinePartUpdateFrame = null
|
|
if (pendingTimelineMessagePartUpdates.size === 0) return
|
|
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
|
pendingTimelineMessagePartUpdates = new Set<string>()
|
|
|
|
const ids = messageIds()
|
|
const resolvedStore = store()
|
|
|
|
setTimelineSegments((prev) => {
|
|
let next = prev
|
|
|
|
for (const changedId of changedIds) {
|
|
// Remove old segments for this message.
|
|
next = next.filter((segment) => segment.messageId !== changedId)
|
|
|
|
const record = resolvedStore.getMessage(changedId)
|
|
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
|
|
|
// Insert rebuilt segments in the correct place based on session message order.
|
|
if (rebuilt.length > 0) {
|
|
let insertAt = next.length
|
|
const changedIndex = ids.indexOf(changedId)
|
|
if (changedIndex >= 0) {
|
|
for (let i = changedIndex + 1; i < ids.length; i++) {
|
|
const followingId = ids[i]
|
|
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
|
if (existingIndex >= 0) {
|
|
insertAt = existingIndex
|
|
break
|
|
}
|
|
}
|
|
}
|
|
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
|
}
|
|
}
|
|
|
|
// Rebuild the segment key set since we may have removed/replaced segments.
|
|
seenTimelineSegmentKeys.clear()
|
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
|
return next
|
|
})
|
|
|
|
// Prune stale selection IDs: segment IDs are positional and change on rebuild.
|
|
setSelectedTimelineIds((prev) => {
|
|
if (prev.size === 0) return prev
|
|
const currentIds = new Set(timelineSegments().map((s) => s.id))
|
|
const pruned = new Set([...prev].filter((id) => currentIds.has(id)))
|
|
return pruned.size === prev.size ? prev : pruned
|
|
})
|
|
})
|
|
}
|
|
|
|
// Keep timeline segments in sync when message parts are added/removed.
|
|
// Part deletion does not remove message ids from the session, so we must
|
|
// explicitly replace segments for messages whose part count changed.
|
|
createEffect(() => {
|
|
if (props.loading) return
|
|
const ids = messageIds()
|
|
const resolvedStore = store()
|
|
|
|
let hasChanges = false
|
|
for (const messageId of ids) {
|
|
const record = resolvedStore.getMessage(messageId)
|
|
const partCount = record?.partIds.length ?? 0
|
|
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
|
|
|
if (previousCount === undefined) {
|
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
|
continue
|
|
}
|
|
|
|
if (previousCount !== partCount) {
|
|
timelinePartCountsByMessageId.set(messageId, partCount)
|
|
pendingTimelineMessagePartUpdates.add(messageId)
|
|
hasChanges = true
|
|
}
|
|
}
|
|
|
|
// Drop tracking for ids that are no longer present.
|
|
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
|
if (!ids.includes(trackedId)) {
|
|
timelinePartCountsByMessageId.delete(trackedId)
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
scheduleTimelinePartUpdateFlush()
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!props.onQuoteSelection) {
|
|
clearQuoteSelection()
|
|
}
|
|
})
|
|
|
|
|
|
createEffect(() => {
|
|
if (typeof document === "undefined") return
|
|
const handleSelectionChange = () => updateQuoteSelectionFromSelection()
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const shell = streamShellElement()
|
|
if (!shell) return
|
|
if (!shell.contains(event.target as Node)) {
|
|
clearQuoteSelection()
|
|
}
|
|
}
|
|
document.addEventListener("selectionchange", handleSelectionChange)
|
|
document.addEventListener("pointerdown", handlePointerDown)
|
|
onCleanup(() => {
|
|
document.removeEventListener("selectionchange", handleSelectionChange)
|
|
document.removeEventListener("pointerdown", handlePointerDown)
|
|
})
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (props.loading) {
|
|
clearQuoteSelection()
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (typeof document === "undefined") return
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && (selectedTimelineIds().size > 0 || selectedForDeletion().size > 0)) {
|
|
clearDeleteMode()
|
|
}
|
|
}
|
|
document.addEventListener("keydown", handleKeyDown)
|
|
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!isDeleteMenuOpen()) return
|
|
if (typeof document === "undefined") return
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target as Node
|
|
if (deleteMenuRef?.contains(target)) return
|
|
if (deleteMenuButtonRef?.contains(target)) return
|
|
setIsDeleteMenuOpen(false)
|
|
}
|
|
document.addEventListener("mousedown", handleClick)
|
|
onCleanup(() => document.removeEventListener("mousedown", handleClick))
|
|
})
|
|
onCleanup(() => {
|
|
clearPendingTimelinePartUpdateFrame()
|
|
clearQuoteSelection()
|
|
})
|
|
|
|
return (
|
|
<div
|
|
class="message-stream-container"
|
|
data-instance-id={props.instanceId}
|
|
data-session-id={props.sessionId}
|
|
data-stream-active={isActive() ? "true" : "false"}
|
|
>
|
|
<div
|
|
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
|
data-scroll-buttons={scrollButtonsCount()}
|
|
>
|
|
<VirtualFollowList
|
|
items={messageIds}
|
|
getKey={(messageId) => messageId}
|
|
getAnchorId={getMessageAnchorId}
|
|
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
|
overscanPx={800}
|
|
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
|
suspendMeasurements={() => !isActive()}
|
|
loading={() => Boolean(props.loading)}
|
|
isActive={isActive}
|
|
scrollToBottomOnActivate={() => false}
|
|
initialScrollToBottom={() => false}
|
|
initialAutoScroll={initialAutoScroll}
|
|
resetKey={() => props.sessionId}
|
|
followToken={followToken}
|
|
onScroll={() => {
|
|
clearQuoteSelection()
|
|
scrollCache.persist(streamElement())
|
|
}}
|
|
onMouseUp={() => handleStreamMouseUp()}
|
|
onClick={(e) => {
|
|
if (selectedTimelineIds().size === 0) return
|
|
const target = e.target as HTMLElement
|
|
if (target.closest("button, a, input, [role='button']")) return
|
|
handleClearTimelineSelection()
|
|
}}
|
|
onActiveKeyChange={(messageId) => {
|
|
if (!messageId) return
|
|
const firstSeg = timelineSegments().find((s) => s.messageId === messageId)
|
|
if (firstSeg) {
|
|
setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id))
|
|
}
|
|
}}
|
|
onScrollElementChange={(element) => {
|
|
setStreamElement(element)
|
|
if (!element) clearQuoteSelection()
|
|
}}
|
|
onShellElementChange={(element) => {
|
|
setStreamShellElement(element)
|
|
if (!element) clearQuoteSelection()
|
|
}}
|
|
scrollToTopAriaLabel={() => t("messageSection.scroll.toFirstAriaLabel")}
|
|
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
|
registerApi={(api) => setListApi(api)}
|
|
registerState={(state) => setListState(state)}
|
|
renderBeforeItems={() => (
|
|
<>
|
|
<Show when={!props.loading && messageIds().length === 0}>
|
|
<div class="empty-state">
|
|
<div class="empty-state-content">
|
|
<div class="flex flex-col items-center gap-3 mb-6">
|
|
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
|
|
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
|
|
</div>
|
|
<h3>{t("messageSection.empty.title")}</h3>
|
|
<p>{t("messageSection.empty.description")}</p>
|
|
<ul>
|
|
<li>
|
|
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
|
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
|
|
</li>
|
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
|
<li>
|
|
{t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={props.loading}>
|
|
<div class="loading-state">
|
|
<div class="spinner" />
|
|
<p>{t("messageSection.loading.messages")}</p>
|
|
</div>
|
|
</Show>
|
|
</>
|
|
)}
|
|
renderItem={(messageId, index) => (
|
|
<MessageBlock
|
|
messageId={messageId}
|
|
instanceId={props.instanceId}
|
|
sessionId={props.sessionId}
|
|
store={store}
|
|
messageIndex={index}
|
|
lastAssistantIndex={lastAssistantIndex}
|
|
showThinking={() => preferences().showThinkingBlocks}
|
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
|
showUsageMetrics={showUsagePreference}
|
|
deleteHover={deleteHover}
|
|
onDeleteHoverChange={setDeleteHover}
|
|
selectedMessageIds={selectedForDeletion}
|
|
selectedToolPartKeys={deleteToolPartKeys}
|
|
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
|
onRevert={props.onRevert}
|
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
|
onFork={props.onFork}
|
|
onContentRendered={handleContentRendered}
|
|
/>
|
|
)}
|
|
renderOverlay={() => (
|
|
<Show when={quoteSelection()}>
|
|
{(selection) => (
|
|
<div class="message-quote-popover" style={{ top: `${selection().top}px`, left: `${selection().left}px` }}>
|
|
<div class="message-quote-button-group">
|
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
|
{t("messageSection.quote.addAsQuote")}
|
|
</button>
|
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
|
{t("messageSection.quote.addAsCode")}
|
|
</button>
|
|
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
|
|
{t("messageSection.quote.copy")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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>
|
|
|
|
<Show when={hasTimelineSegments()}>
|
|
<div class="message-timeline-sidebar">
|
|
<MessageTimeline
|
|
segments={timelineSegments()}
|
|
onSegmentClick={handleTimelineSegmentClick}
|
|
onToggleSelection={handleToggleTimelineSelection}
|
|
onLongPressSelection={handleLongPressTimelineSelection}
|
|
onSelectRange={handleSelectRangeTimeline}
|
|
onClearSelection={handleClearTimelineSelection}
|
|
selectedIds={selectedTimelineIds}
|
|
expandedMessageIds={expandedMessageIds}
|
|
deletableMessageIds={deletableMessageIds}
|
|
activeSegmentId={activeSegmentId()}
|
|
instanceId={props.instanceId}
|
|
sessionId={props.sessionId}
|
|
showToolSegments={showTimelineToolsPreference()}
|
|
deleteHover={deleteHover}
|
|
onDeleteHoverChange={setDeleteHover}
|
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
|
selectedMessageIds={selectedForDeletion}
|
|
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|