Merge origin/dev into dev

This commit is contained in:
Shantur Rathore
2026-03-03 22:57:43 +00:00
12 changed files with 1534 additions and 262 deletions

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { CheckSquare, Trash, X } from "lucide-solid"
import { MoreHorizontal, Trash, X } from "lucide-solid"
import Kbd from "./kbd"
import MessageBlock from "./message-block"
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
@@ -13,9 +13,11 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 48
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
@@ -85,16 +87,234 @@ export default function MessageSection(props: MessageSectionProps) {
})
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
const api = listApi()
if (api) {
api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" })
const scrollToMessage = () => {
const api = listApi()
if (api) {
api.scrollToKey(segment.messageId, { behavior: "smooth", block: "start" })
return
}
if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
}
if (selectionMode() === "tools" && segment.type !== "tool") {
setActiveSegmentId(segment.id)
scrollToMessage()
return
}
if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
setLastSelectionAnchorId(segment.id)
setActiveSegmentId(segment.id)
scrollToMessage()
}
const [selectedTimelineIds, setSelectedTimelineIds] = createSignal<Set<string>>(new Set())
const [lastSelectionAnchorId, setLastSelectionAnchorId] = createSignal<string | null>(null)
const [expandedMessageIds, setExpandedMessageIds] = createSignal<Set<string>>(new Set())
const [selectionMode, setSelectionMode] = createSignal<"all" | "tools">("all")
const [isDeleteMenuOpen, setIsDeleteMenuOpen] = createSignal(false)
let deleteMenuRef: HTMLDivElement | undefined
let deleteMenuButtonRef: HTMLButtonElement | undefined
// Deletion is only allowed for messages/tool parts that occur AFTER the most
// recent compaction. Compaction effectively resets the stored context; deleting
// earlier items would not reliably reflect what the model sees.
const messageIndexById = createMemo(() => {
const ids = messageIds()
const map = new Map<string, number>()
for (let i = 0; i < ids.length; i++) {
map.set(ids[i], i)
}
return map
})
const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small.
sessionRevision()
return untrack(() => store().getLastCompactionMessageIndex(props.sessionId))
})
const deletableStartIndex = createMemo(() => {
const idx = lastCompactionIndex()
return idx === -1 ? 0 : idx + 1
})
const deletableMessageIds = createMemo(() => {
const ids = messageIds()
const start = deletableStartIndex()
return new Set(ids.slice(start))
})
const isMessageDeletable = (messageId: string): boolean => {
const idx = messageIndexById().get(messageId)
if (idx === undefined) return false
return idx >= deletableStartIndex()
}
// Build the message group for a segment.
// Tool calls belong to the same assistant turn (between user messages).
// Only assistant badges trigger group selection; user/tool badges are standalone.
const getAdjacentGroup = (_clickedIndex: number, segments: TimelineSegment[]): TimelineSegment[] => {
const clicked = segments[_clickedIndex]
if (clicked.type === "assistant") {
let currentTurn = -1
const turnByMessageId = new Map<string, number>()
for (const segment of segments) {
if (segment.type === "user") {
currentTurn += 1
continue
}
if (currentTurn === -1) currentTurn = 0
if (!turnByMessageId.has(segment.messageId)) {
turnByMessageId.set(segment.messageId, currentTurn)
}
}
const turnIndex = turnByMessageId.get(clicked.messageId)
if (turnIndex === undefined) {
return segments.filter((s) => s.messageId === clicked.messageId)
}
return segments.filter((s) => s.type !== "user" && turnByMessageId.get(s.messageId) === turnIndex)
}
// User, tool, and compaction segments are standalone.
return [clicked]
}
const handleToggleTimelineSelection = (id: string) => {
const segments = timelineSegments()
const segmentIndex = segments.findIndex((s) => s.id === id)
if (segmentIndex === -1) return
const segment = segments[segmentIndex]
if (!isMessageDeletable(segment.messageId)) {
return
}
setLastSelectionAnchorId(id)
if (selectionMode() === "tools" && segment.type !== "tool") {
return
}
const selected = selectedTimelineIds()
const isCurrentlySelected = selected.has(id)
const group = getAdjacentGroup(segmentIndex, segments)
const hasToolsInGroup = group.some((s) => s.type === "tool")
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
const selectedInGroup = isGroupCandidate
? group.reduce((count, s) => (selected.has(s.id) ? count + 1 : count), 0)
: 0
const isGroupEmpty = isGroupCandidate && selectedInGroup === 0
if (isGroupCandidate && !isCurrentlySelected && isGroupEmpty) {
// Parent click: select entire group only when none are selected yet.
// Tool visibility is handled by isSelectionActive() in isHidden() — no
// expand/collapse needed.
setSelectedTimelineIds((prev) => {
const next = new Set(prev)
for (const s of group) next.add(s.id)
return next
})
} else if (isCurrentlySelected) {
// Individual deselect (tool or parent). No group deselect.
const newSelected = new Set(selected)
newSelected.delete(id)
setSelectedTimelineIds(newSelected)
} else {
// Individual select (tool badge, parent with partial group, or standalone).
setSelectedTimelineIds((prev) => {
const next = new Set(prev)
next.add(id)
return next
})
}
}
const handleLongPressTimelineSelection = (segment: TimelineSegment) => {
const segments = timelineSegments()
const segmentIndex = segments.findIndex((s) => s.id === segment.id)
if (segmentIndex === -1) return
if (!isMessageDeletable(segment.messageId)) {
return
}
setLastSelectionAnchorId(segment.id)
if (selectionMode() === "tools" && segment.type !== "tool") {
return
}
const group = getAdjacentGroup(segmentIndex, segments)
const hasToolsInGroup = group.some((s) => s.type === "tool")
const isGroupCandidate = segment.type === "assistant" && hasToolsInGroup
if (!isGroupCandidate) {
handleToggleTimelineSelection(segment.id)
return
}
const selected = selectedTimelineIds()
const hasAnySelected = group.some((s) => selected.has(s.id))
if (!hasAnySelected) {
setSelectedTimelineIds((prev) => {
const next = new Set(prev)
for (const s of group) next.add(s.id)
return next
})
return
}
const newSelected = new Set(selected)
for (const s of group) newSelected.delete(s.id)
setSelectedTimelineIds(newSelected)
}
const handleSelectRangeTimeline = (id: string) => {
const anchorId = lastSelectionAnchorId()
if (!anchorId) {
handleToggleTimelineSelection(id)
return
}
const segments = timelineSegments()
const anchorIndex = segments.findIndex((s) => s.id === anchorId)
const targetIndex = segments.findIndex((s) => s.id === id)
if (anchorIndex === -1 || targetIndex === -1) {
handleToggleTimelineSelection(id)
return
}
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
const rangeSegments = selectionMode() === "tools"
? segments.slice(start, end + 1).filter((s) => s.type === "tool" && isMessageDeletable(s.messageId))
: segments.slice(start, end + 1).filter((s) => isMessageDeletable(s.messageId))
// Range selection replaces current selection so it can grow or shrink.
setSelectedTimelineIds(new Set(rangeSegments.map((segment) => segment.id)))
}
const handleClearTimelineSelection = () => {
setSelectedTimelineIds(new Set<string>())
setLastSelectionAnchorId(null)
}
const applySelectionMode = (mode: "all" | "tools") => {
setSelectionMode(mode)
if (mode !== "tools") return
const segments = timelineSegments()
const toolIds = new Set(
segments
.filter((segment) => segment.type === "tool" && isMessageDeletable(segment.messageId))
.map((segment) => segment.id),
)
setSelectedTimelineIds((prev) => {
if (prev.size === 0) return prev
const next = new Set([...prev].filter((id) => toolIds.has(id)))
if (next.size === 0) setLastSelectionAnchorId(null)
return next
})
}
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
@@ -160,18 +380,102 @@ export default function MessageSection(props: MessageSectionProps) {
setTimelineSegments((prev) => [...prev, ...newSegments])
}
}
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
const selectedDeleteCount = createMemo(() => selectedForDeletion().size)
const selectedToolParts = createMemo(() => {
const selected = selectedTimelineIds()
if (selected.size === 0) return [] as { messageId: string; partId: string }[]
const segments = timelineSegments()
const segmentById = new Map<string, TimelineSegment>()
for (const segment of segments) segmentById.set(segment.id, segment)
const toolParts: { messageId: string; partId: string }[] = []
const seen = new Set<string>()
for (const segId of selected) {
const segment = segmentById.get(segId)
if (!segment || segment.type !== "tool") continue
for (const partId of segment.toolPartIds ?? []) {
if (!partId) continue
const key = `${segment.messageId}:${partId}`
if (seen.has(key)) continue
seen.add(key)
toolParts.push({ messageId: segment.messageId, partId })
}
}
return toolParts
})
const deleteMessageIds = createMemo(() => selectedForDeletion())
const deleteToolParts = createMemo(() => {
const messageIds = deleteMessageIds()
const allowed = deletableMessageIds()
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
})
const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
const selectedTokenTotal = createMemo(() => {
const selected = deleteMessageIds()
const toolParts = deleteToolParts()
if (selected.size === 0 && toolParts.length === 0) return 0
// Fresh-from-store chars: read parts directly via buildRecordDisplayData +
// getPartCharCount so the toolbar stays consistent with the xray overlay
// (which also reads live from the store). Falls back to segment totalChars
// when no record is found (e.g. compaction segments).
const s = store()
let total = 0
for (const messageId of selected) {
let chars = 0
const record = s.getMessage(messageId)
if (record) {
const displayData = buildRecordDisplayData(props.instanceId, record)
for (const part of displayData.orderedParts) {
chars += getPartCharCount(part)
}
} else {
// Fallback: sum from segments (O(n) pre-pass scoped to this branch)
for (const seg of timelineSegments()) {
if (seg.messageId === messageId) chars += seg.totalChars
}
}
total += Math.max(Math.round(chars / 4), 1)
}
if (toolParts.length > 0) {
const partFallbackChars = new Map<string, number>()
for (const segment of timelineSegments()) {
if (segment.type !== "tool") continue
for (const partId of segment.toolPartIds ?? []) {
if (!partId || partFallbackChars.has(partId)) continue
partFallbackChars.set(partId, segment.totalChars)
}
}
for (const { messageId, partId } of toolParts) {
let chars = 0
const record = s.getMessage(messageId)
const partRecord = record?.parts?.[partId]
if (partRecord?.data) {
chars = getPartCharCount(partRecord.data)
} else {
chars = partFallbackChars.get(partId) ?? 0
}
total += Math.max(Math.round(chars / 4), 1)
}
}
return total
})
const formatTokenCount = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return String(tokens)
}
const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
if (!messageId) return
if (!isMessageDeletable(messageId)) return
setSelectedForDeletion((prev) => {
const next = new Set(prev)
if (selected) {
@@ -186,21 +490,50 @@ export default function MessageSection(props: MessageSectionProps) {
const clearDeleteMode = () => {
setSelectedForDeletion(new Set<string>())
setDeleteHover({ kind: "none" })
setSelectedTimelineIds(new Set<string>())
setLastSelectionAnchorId(null)
}
createEffect(() => {
const timelineIds = selectedTimelineIds()
if (timelineIds.size === 0) {
setSelectedForDeletion(new Set<string>())
return
}
const segments = timelineSegments()
const segmentById = new Map<string, TimelineSegment>()
for (const segment of segments) segmentById.set(segment.id, segment)
const affectedMessageIds = new Set<string>()
for (const segId of timelineIds) {
const segment = segmentById.get(segId)
if (segment && segment.type !== "tool" && isMessageDeletable(segment.messageId)) {
affectedMessageIds.add(segment.messageId)
}
}
setSelectedForDeletion(affectedMessageIds)
})
const selectAllForDeletion = () => {
setSelectedForDeletion(new Set<string>(messageIds()))
const allMessageIds = [...deletableMessageIds()]
setSelectedForDeletion(new Set<string>(allMessageIds))
// Also select all timeline segments — tool visibility is handled by
// isSelectionActive() in isHidden(), no expand/collapse needed.
const segments = timelineSegments()
setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id)))
}
const deleteSelectedMessages = async () => {
const selected = selectedForDeletion()
if (selected.size === 0) return
const selected = deleteMessageIds()
const toolParts = deleteToolParts()
if (selected.size === 0 && toolParts.length === 0) return
const allowed = deletableMessageIds()
const idsInSessionOrder = messageIds()
const toDelete: string[] = []
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
const id = idsInSessionOrder[idx]
if (selected.has(id)) {
if (allowed.has(id) && selected.has(id)) {
toDelete.push(id)
}
}
@@ -209,6 +542,10 @@ export default function MessageSection(props: MessageSectionProps) {
for (const messageId of toDelete) {
await deleteMessage(props.instanceId, props.sessionId, messageId)
}
for (const { messageId, partId } of toolParts) {
if (!allowed.has(messageId)) continue
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
}
clearDeleteMode()
} catch (error) {
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
@@ -391,6 +728,7 @@ export default function MessageSection(props: MessageSectionProps) {
const ids = messageIds()
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
@@ -524,6 +862,14 @@ export default function MessageSection(props: MessageSectionProps) {
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Prune stale selection IDs: segment IDs are positional and change on rebuild.
setSelectedTimelineIds((prev) => {
if (prev.size === 0) return prev
const currentIds = new Set(timelineSegments().map((s) => s.id))
const pruned = new Set([...prev].filter((id) => currentIds.has(id)))
return pruned.size === prev.size ? prev : pruned
})
})
}
@@ -596,6 +942,29 @@ export default function MessageSection(props: MessageSectionProps) {
}
})
createEffect(() => {
if (typeof document === "undefined") return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && (selectedTimelineIds().size > 0 || selectedForDeletion().size > 0)) {
clearDeleteMode()
}
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
createEffect(() => {
if (!isDeleteMenuOpen()) return
if (typeof document === "undefined") return
const handleClick = (event: MouseEvent) => {
const target = event.target as Node
if (deleteMenuRef?.contains(target)) return
if (deleteMenuButtonRef?.contains(target)) return
setIsDeleteMenuOpen(false)
}
document.addEventListener("mousedown", handleClick)
onCleanup(() => document.removeEventListener("mousedown", handleClick))
})
onCleanup(() => {
clearPendingTimelinePartUpdateFrame()
clearQuoteSelection()
@@ -612,31 +981,37 @@ export default function MessageSection(props: MessageSectionProps) {
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
data-scroll-buttons={scrollButtonsCount()}
>
<VirtualFollowList
items={messageIds}
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId}
overscanPx={800}
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
suspendMeasurements={() => !isActive()}
loading={() => Boolean(props.loading)}
isActive={isActive}
scrollToBottomOnActivate={() => false}
initialScrollToBottom={() => false}
initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId}
followToken={followToken}
onScroll={() => {
clearQuoteSelection()
scrollCache.persist(streamElement())
}}
onMouseUp={() => handleStreamMouseUp()}
onActiveKeyChange={setActiveMessageId}
onScrollElementChange={(element) => {
setStreamElement(element)
if (!element) clearQuoteSelection()
}}
<VirtualFollowList
items={messageIds}
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId}
overscanPx={800}
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
suspendMeasurements={() => !isActive()}
loading={() => Boolean(props.loading)}
isActive={isActive}
scrollToBottomOnActivate={() => false}
initialScrollToBottom={() => false}
initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId}
followToken={followToken}
onScroll={() => {
clearQuoteSelection()
scrollCache.persist(streamElement())
}}
onMouseUp={() => handleStreamMouseUp()}
onActiveKeyChange={(messageId) => {
if (!messageId) return
const firstSeg = timelineSegments().find((s) => s.messageId === messageId)
if (firstSeg) {
setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id))
}
}}
onScrollElementChange={(element) => {
setStreamElement(element)
if (!element) clearQuoteSelection()
}}
onShellElementChange={(element) => {
setStreamShellElement(element)
if (!element) clearQuoteSelection()
@@ -651,12 +1026,7 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
<img
src={codeNomadLogo}
alt={t("messageSection.empty.logoAlt")}
class="h-48 w-auto"
loading="lazy"
/>
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
</div>
<h3>{t("messageSection.empty.title")}</h3>
@@ -725,12 +1095,138 @@ export default function MessageSection(props: MessageSectionProps) {
)}
/>
<Show when={isDeleteMode()}>
<div
class="message-delete-mode-toolbar"
role="toolbar"
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
>
<div class="message-delete-mode-toolbar-row" aria-hidden="true">
<span class="message-delete-mode-token-group">
<span class="message-delete-mode-count message-delete-mode-count--before" title={`${tokenStats().used} tokens currently in context`}>
{formatTokenCount(tokenStats().used)}
</span>
<span class="message-delete-mode-arrow" aria-hidden="true">{"\u203A"}</span>
<span
class="message-delete-mode-count message-delete-mode-count--selection"
title={`${selectedTokenTotal()} tokens selected (${selectedDeleteCount()} messages)`}
>
{formatTokenCount(selectedTokenTotal())}
</span>
<span class="message-delete-mode-arrow" aria-hidden="true">{"\u203A"}</span>
<span
class="message-delete-mode-count message-delete-mode-count--after"
title={`${Math.max(0, tokenStats().used - selectedTokenTotal())} tokens remaining after deletion`}
>
{formatTokenCount(Math.max(0, tokenStats().used - selectedTokenTotal()))}
</span>
</span>
<button
type="button"
class="message-delete-mode-button message-delete-mode-button--delete"
onClick={() => void deleteSelectedMessages()}
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
>
<Trash class="w-4 h-4" aria-hidden="true" />
</button>
<div class="message-delete-mode-menu-container">
<button
ref={(el) => {
deleteMenuButtonRef = el
}}
type="button"
class="message-delete-mode-button message-delete-mode-button--menu"
onClick={() => setIsDeleteMenuOpen((prev) => !prev)}
title={t("messageSection.bulkDelete.moreOptionsTitle")}
aria-label={t("messageSection.bulkDelete.moreOptionsTitle")}
>
<MoreHorizontal class="w-4 h-4" aria-hidden="true" />
</button>
<Show when={isDeleteMenuOpen()}>
<div
ref={(el) => {
deleteMenuRef = el
}}
class="message-delete-mode-menu dropdown-surface"
>
<button
type="button"
class="dropdown-item"
onClick={() => {
selectAllForDeletion()
setIsDeleteMenuOpen(false)
}}
>
{t("messageSection.bulkDelete.selectAllTitle")}
</button>
<div class="message-delete-mode-menu-divider" aria-hidden="true" />
<div class="message-delete-mode-menu-row">
<span class="message-delete-mode-menu-label">{t("messageSection.bulkDelete.selectionModeLabel")}</span>
<div class="message-delete-mode-menu-toggle">
<button
type="button"
class="message-delete-mode-menu-toggle-button"
data-mode="all"
data-active={selectionMode() === "all"}
onClick={() => applySelectionMode("all")}
>
{t("messageSection.bulkDelete.selectionModeAll")}
</button>
<button
type="button"
class="message-delete-mode-menu-toggle-button"
data-mode="tools"
data-active={selectionMode() === "tools"}
onClick={() => applySelectionMode("tools")}
>
{t("messageSection.bulkDelete.selectionModeTools")}
</button>
</div>
</div>
</div>
</Show>
</div>
<button
type="button"
class="message-delete-mode-button message-delete-mode-button--cancel"
onClick={clearDeleteMode}
title={t("messageSection.bulkDelete.cancelTitle")}
aria-label={t("messageSection.bulkDelete.cancelTitle")}
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="message-delete-mode-hint-row keyboard-hints" aria-hidden="true">
<Kbd shortcut="cmd+click" />
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.toggle")}</span>
<span class="message-delete-mode-hint-sep">·</span>
<Kbd shortcut="shift+click" />
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.range")}</span>
<span class="message-delete-mode-hint-sep">·</span>
<Kbd shortcut="esc" />
<span class="message-delete-mode-hint-text">{t("messageSection.bulkDelete.selectionHint.clear")}</span>
</div>
</div>
</Show>
<Show when={hasTimelineSegments()}>
<div class="message-timeline-sidebar">
<MessageTimeline
segments={timelineSegments()}
onSegmentClick={handleTimelineSegmentClick}
activeMessageId={activeMessageId()}
onToggleSelection={handleToggleTimelineSelection}
onLongPressSelection={handleLongPressTimelineSelection}
onSelectRange={handleSelectRangeTimeline}
onClearSelection={handleClearTimelineSelection}
selectedIds={selectedTimelineIds}
expandedMessageIds={expandedMessageIds}
deletableMessageIds={deletableMessageIds}
activeSegmentId={activeSegmentId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()}
@@ -742,48 +1238,6 @@ export default function MessageSection(props: MessageSectionProps) {
/>
</div>
</Show>
<Show when={isDeleteMode()}>
<div
class="message-delete-mode-toolbar"
role="toolbar"
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
>
<span class="message-delete-mode-count" aria-hidden="true">
{selectedDeleteCount()}
</span>
<button
type="button"
class="message-delete-mode-button"
onClick={() => void deleteSelectedMessages()}
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
>
<Trash class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={selectAllForDeletion}
title={t("messageSection.bulkDelete.selectAllTitle")}
aria-label={t("messageSection.bulkDelete.selectAllTitle")}
>
<CheckSquare class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={clearDeleteMode}
title={t("messageSection.bulkDelete.cancelTitle")}
aria-label={t("messageSection.bulkDelete.cancelTitle")}
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</Show>
</div>
</div>
)