chore(ui): finalize timeline selection audit fixes
Complete re-review of PR #188 (commits224cab6feature +2c27fc5perf/i18n follow-up). Gatekeeper focus: standards, correctness, perf/complexity, and translation completeness. What this changes (pre -> post) Pre: timeline primarily navigation/hover preview; bulk delete selection message-level and token metrics tied to backend assistant output tokens (missing tool payload weight). Post: segment-level timeline selection + range (Shift) + toggle (Ctrl/Meta) + mobile long-press; histogram ribs overlay showing relative + absolute (~10k cap) token weight; assistant-turn grouping to avoid adjacency bugs; bulk-delete toolbar shows Before / Selection / After token pills. Code standards / correctness OK: Solid signal/memo/effect patterns with cleanup; no obvious lifecycle leaks. Grouping avoids adjacency overlap by mapping messageId to turns. Fix: selection-id stability is mitigated by pruning stale ids after segment rebuilds; long term stable ids from part ids/toolPartIds remain recommended. Fix: token counts now share getPartCharCount in both x-ray overlay and bulk-delete toolbar, keeping estimates consistent with live store updates. Performance / complexity OK: O(n^2) hotspots removed for liveSegmentChars and selectedTokenTotal. groupRole + deleteUpTo hover checks now memoize messageId sets/maps. Note: getPartCharCount can be heavy for large tool payloads but remains gated behind selection mode. CSS / UI integration Fix: x-ray token label now uses theme tokens instead of hard-coded colors. Delete toolbar now uses menu-based controls with selection-mode toggle. i18n Fix: selection hint now renders Cmd/Ctrl via localized modifier placeholder; all locales updated.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
@@ -14,6 +14,9 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } 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"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -78,6 +81,13 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
if (selectionMode() === "tools" && segment.type !== "tool") {
|
||||
setActiveSegmentId(segment.id)
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
return
|
||||
}
|
||||
setLastSelectionAnchorId(segment.id)
|
||||
setActiveSegmentId(segment.id)
|
||||
if (typeof document === "undefined") return
|
||||
@@ -88,17 +98,34 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
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
|
||||
|
||||
// Build the message group for a segment.
|
||||
// Tool calls belong to the same message as their assistant. Only the
|
||||
// assistant badge triggers group selection; user badges are standalone.
|
||||
// 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") {
|
||||
// Group = all segments from the same message (assistant + its tools).
|
||||
// Uses messageId instead of positional adjacency to avoid cross-message
|
||||
// overlap when tool-only messages produce no assistant segment separator.
|
||||
return segments.filter((s) => s.messageId === clicked.messageId)
|
||||
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]
|
||||
@@ -111,63 +138,36 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
if (segmentIndex === -1) return
|
||||
const segment = segments[segmentIndex]
|
||||
|
||||
const isCurrentlySelected = selectedTimelineIds().has(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 toolMsgIds = new Set(group.filter((s) => s.type === "tool").map((s) => s.messageId))
|
||||
const isGroupExpanded = toolMsgIds.size > 0 && [...toolMsgIds].every((mid) => expandedMessageIds().has(mid))
|
||||
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 (!isCurrentlySelected && (segment.type === "assistant" || segment.type === "user") && hasToolsInGroup && !isGroupExpanded) {
|
||||
// First click on a parent with sibling tools: expand + select entire group
|
||||
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
|
||||
})
|
||||
setExpandedMessageIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const s of group) {
|
||||
if (s.type === "tool") next.add(s.messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else if (isCurrentlySelected) {
|
||||
if ((segment.type === "assistant" || segment.type === "user") && isGroupExpanded) {
|
||||
// Parent re-click: collapse + deselect entire group
|
||||
const newSelected = new Set(selectedTimelineIds())
|
||||
for (const s of group) newSelected.delete(s.id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
setExpandedMessageIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const s of group) {
|
||||
if (s.type === "tool") next.delete(s.messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else if (segment.type === "tool") {
|
||||
// Individual tool deselect
|
||||
const newSelected = new Set(selectedTimelineIds())
|
||||
newSelected.delete(id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
// Collapse tool's messageId if no other selected segment needs it
|
||||
const anyOtherSelected = group.some((s) => s.type === "tool" && s.id !== id && newSelected.has(s.id))
|
||||
if (!anyOtherSelected) {
|
||||
setExpandedMessageIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const s of group) {
|
||||
if (s.type === "tool") next.delete(s.messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Deselect just this non-tool segment
|
||||
const newSelected = new Set(selectedTimelineIds())
|
||||
newSelected.delete(id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
}
|
||||
// Individual deselect (tool or parent). No group deselect.
|
||||
const newSelected = new Set(selected)
|
||||
newSelected.delete(id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
} else {
|
||||
// Select just this segment (tool badge or already-expanded parent)
|
||||
// Individual select (tool badge, parent with partial group, or standalone).
|
||||
setSelectedTimelineIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(id)
|
||||
@@ -176,6 +176,27 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLongPressTimelineSelection = (segment: TimelineSegment) => {
|
||||
setLastSelectionAnchorId(segment.id)
|
||||
const segments = timelineSegments()
|
||||
const segmentIndex = segments.findIndex((s) => s.id === segment.id)
|
||||
if (segmentIndex === -1) return
|
||||
|
||||
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 newSelected = new Set(selectedTimelineIds())
|
||||
for (const s of group) newSelected.delete(s.id)
|
||||
setSelectedTimelineIds(newSelected)
|
||||
}
|
||||
|
||||
const handleSelectRangeTimeline = (id: string) => {
|
||||
const anchorId = lastSelectionAnchorId()
|
||||
if (!anchorId) {
|
||||
@@ -195,58 +216,29 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const start = Math.min(anchorIndex, targetIndex)
|
||||
const end = Math.max(anchorIndex, targetIndex)
|
||||
|
||||
// Range action follows the anchor state:
|
||||
// - If the anchor is selected → add the range (extend selection)
|
||||
// - If the anchor is NOT selected → remove the range (extend deselection)
|
||||
const anchorSelected = selectedTimelineIds().has(anchorId)
|
||||
|
||||
if (anchorSelected) {
|
||||
// Additive: select everything in range
|
||||
const messagesToExpand = new Set<string>()
|
||||
setSelectedTimelineIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(segments[i].id)
|
||||
if (segments[i].type === "tool") messagesToExpand.add(segments[i].messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (messagesToExpand.size > 0) {
|
||||
setExpandedMessageIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const msgId of messagesToExpand) next.add(msgId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Subtractive: deselect everything in range
|
||||
const messagesToCollapse = new Set<string>()
|
||||
const newSelected = new Set(selectedTimelineIds())
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelected.delete(segments[i].id)
|
||||
if (segments[i].type === "tool") messagesToCollapse.add(segments[i].messageId)
|
||||
}
|
||||
setSelectedTimelineIds(newSelected)
|
||||
if (messagesToCollapse.size > 0) {
|
||||
setExpandedMessageIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const msgId of messagesToCollapse) {
|
||||
// Only collapse if no other selected segment still needs this message expanded
|
||||
const stillNeeded = segments.some((s) =>
|
||||
s.messageId === msgId && s.type === "tool" && newSelected.has(s.id)
|
||||
)
|
||||
if (!stillNeeded) next.delete(msgId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
const rangeSegments = selectionMode() === "tools"
|
||||
? segments.slice(start, end + 1).filter((s) => s.type === "tool")
|
||||
: segments.slice(start, end + 1)
|
||||
// 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)
|
||||
setExpandedMessageIds(new Set<string>())
|
||||
}
|
||||
|
||||
const applySelectionMode = (mode: "all" | "tools") => {
|
||||
setSelectionMode(mode)
|
||||
if (mode !== "tools") return
|
||||
const segments = timelineSegments()
|
||||
const toolIds = new Set(segments.filter((segment) => segment.type === "tool").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(() => {
|
||||
@@ -325,15 +317,27 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const selectedTokenTotal = createMemo(() => {
|
||||
const selected = selectedForDeletion()
|
||||
if (selected.size === 0) return 0
|
||||
// O(n) pre-pass: aggregate chars by messageId once.
|
||||
const charsByMessageId: Record<string, number> = {}
|
||||
for (const seg of timelineSegments()) {
|
||||
charsByMessageId[seg.messageId] = (charsByMessageId[seg.messageId] ?? 0) + seg.totalChars
|
||||
}
|
||||
// O(selected.size) lookup pass.
|
||||
// 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) {
|
||||
total += Math.max(Math.round((charsByMessageId[messageId] ?? 0) / 4), 1)
|
||||
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)
|
||||
}
|
||||
return total
|
||||
})
|
||||
@@ -364,7 +368,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
setDeleteHover({ kind: "none" })
|
||||
setSelectedTimelineIds(new Set<string>())
|
||||
setLastSelectionAnchorId(null)
|
||||
setExpandedMessageIds(new Set<string>())
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -385,14 +388,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const selectAllForDeletion = () => {
|
||||
const allMessageIds = messageIds()
|
||||
setSelectedForDeletion(new Set<string>(allMessageIds))
|
||||
// Also select all timeline segments and expand tool groups
|
||||
// Also select all timeline segments — tool visibility is handled by
|
||||
// isSelectionActive() in isHidden(), no expand/collapse needed.
|
||||
const segments = timelineSegments()
|
||||
setSelectedTimelineIds(new Set(segments.map((s) => s.id)))
|
||||
const toolMessageIds = new Set<string>()
|
||||
for (const seg of segments) {
|
||||
if (seg.type === "tool") toolMessageIds.add(seg.messageId)
|
||||
}
|
||||
setExpandedMessageIds(toolMessageIds)
|
||||
}
|
||||
|
||||
const deleteSelectedMessages = async () => {
|
||||
@@ -913,6 +912,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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1015,6 +1022,19 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
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
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
@@ -1148,7 +1168,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<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" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
@@ -1277,15 +1305,65 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<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>
|
||||
<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"
|
||||
@@ -1298,7 +1376,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
</button>
|
||||
|
||||
<span class="message-delete-mode-hint keyboard-hints" aria-hidden="true">
|
||||
{t("messageSection.bulkDelete.selectionHint")}
|
||||
{t("messageSection.bulkDelete.selectionHint", { modifier: isMac() ? "Cmd" : "Ctrl" })}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -1310,6 +1388,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
onToggleSelection={handleToggleTimelineSelection}
|
||||
onLongPressSelection={handleLongPressTimelineSelection}
|
||||
onSelectRange={handleSelectRangeTimeline}
|
||||
onClearSelection={handleClearTimelineSelection}
|
||||
selectedIds={selectedTimelineIds}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -30,6 +31,7 @@ interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
onToggleSelection?: (id: string) => void
|
||||
onLongPressSelection?: (segment: TimelineSegment) => void
|
||||
onSelectRange?: (id: string) => void
|
||||
onClearSelection?: () => void
|
||||
selectedIds?: Accessor<Set<string>>
|
||||
@@ -68,67 +70,6 @@ function truncateText(value: string): string {
|
||||
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function getPartCharCount(part: ClientPart): number {
|
||||
if (!part) return 0
|
||||
let count = 0
|
||||
|
||||
if (typeof (part as any).text === "string") {
|
||||
count += (part as any).text.length
|
||||
}
|
||||
|
||||
if (part.type === "tool") {
|
||||
const state = (part as any).state
|
||||
if (state) {
|
||||
if (state.input) {
|
||||
try {
|
||||
count += JSON.stringify(state.input).length
|
||||
} catch {}
|
||||
}
|
||||
if (state.output) {
|
||||
if (typeof state.output === "string") {
|
||||
count += state.output.length
|
||||
} else {
|
||||
try {
|
||||
count += JSON.stringify(state.output).length
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (state.metadata) {
|
||||
for (const [key, val] of Object.entries(state.metadata)) {
|
||||
// Skip filediff — it contains full before/after file content and
|
||||
// would inflate the character count by 10-100x for large files.
|
||||
if (key === "filediff") continue
|
||||
if (typeof val === "string") {
|
||||
count += val.length
|
||||
} else if (val && typeof val === "object") {
|
||||
try {
|
||||
count += JSON.stringify(val).length
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray((part as any).content)) {
|
||||
count += (part as any).content.reduce((acc: number, entry: unknown) => {
|
||||
if (typeof entry === "string") return acc + entry.length
|
||||
if (entry && typeof entry === "object") {
|
||||
let entryCount = (String((entry as any).text || "")).length + (String((entry as any).value || "")).length
|
||||
if (Array.isArray((entry as any).content)) {
|
||||
entryCount += (entry as any).content.reduce((innerAcc: number, sub: unknown) => {
|
||||
if (typeof sub === "string") return innerAcc + sub.length
|
||||
return innerAcc + (String((sub as any)?.text || "")).length
|
||||
}, 0)
|
||||
}
|
||||
return acc + entryCount
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function collectReasoningText(part: ClientPart): string {
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
@@ -610,7 +551,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
|
||||
const getSegmentTokens = (segment: TimelineSegment): number => {
|
||||
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||
if (!isExpanded && (segment.type === "assistant" || segment.type === "user")) {
|
||||
// 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
|
||||
@@ -641,7 +586,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
let wasLongPress = false
|
||||
let pressStartPos = { x: 0, y: 0 }
|
||||
|
||||
const handlePointerDown = (id: string, event: PointerEvent) => {
|
||||
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
wasLongPress = false
|
||||
pressStartPos = { x: event.clientX, y: event.clientY }
|
||||
@@ -659,13 +604,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
wasLongPress = true
|
||||
|
||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||
const btn = buttonRefs.get(id)
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
props.onToggleSelection?.(id)
|
||||
if (props.onLongPressSelection) {
|
||||
props.onLongPressSelection(segment)
|
||||
} else {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
}
|
||||
|
||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||
const desired = btn.offsetTop - anchorOffset
|
||||
@@ -741,6 +690,25 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
return { messageId: segment.messageId }
|
||||
})
|
||||
|
||||
// Pre-computed set of messageIds that have at least one tool segment.
|
||||
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
|
||||
const messagesWithTools = createMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const s of props.segments) {
|
||||
if (s.type === "tool") set.add(s.messageId)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
// Pre-computed index map for session message ordering.
|
||||
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
|
||||
const messageIdToSessionIndex = createMemo(() => {
|
||||
const ids = store().getSessionMessageIds(props.sessionId)
|
||||
const map = new Map<string, number>()
|
||||
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
|
||||
return map
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
@@ -763,11 +731,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const ids = store().getSessionMessageIds(props.sessionId)
|
||||
const targetIndex = ids.indexOf(hover.messageId)
|
||||
if (targetIndex === -1) return false
|
||||
const segmentIndex = ids.indexOf(segment.messageId)
|
||||
if (segmentIndex === -1) return false
|
||||
const indexMap = messageIdToSessionIndex()
|
||||
const targetIndex = indexMap.get(hover.messageId)
|
||||
if (targetIndex === undefined) return false
|
||||
const segmentIndex = indexMap.get(segment.messageId)
|
||||
if (segmentIndex === undefined) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
@@ -792,12 +760,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||
const groupRole = (): "child" | "parent" | "none" => {
|
||||
if (segment.type === "tool") return "child"
|
||||
if (segment.type === "assistant") {
|
||||
const hasSiblingTools = props.segments.some(
|
||||
(s) => s.messageId === segment.messageId && s.type === "tool",
|
||||
)
|
||||
if (hasSiblingTools) return "parent"
|
||||
}
|
||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||
return "none"
|
||||
}
|
||||
const isGroupStart = () => {
|
||||
@@ -857,7 +820,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
props.onClearSelection?.()
|
||||
// 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)
|
||||
}
|
||||
@@ -871,7 +837,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(segment.id, e)}
|
||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
|
||||
@@ -86,10 +86,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Selected messages ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected 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": "{modifier}+Click toggle · Shift+Click range · Esc clear",
|
||||
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
|
||||
"messageSection.bulkDelete.failedTitle": "Delete failed",
|
||||
"messageSection.bulkDelete.failedMessage": "Failed to delete selected messages",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "QUEUED",
|
||||
"messageItem.status.generating": "Generating...",
|
||||
"messageItem.status.sending": "Sending...",
|
||||
|
||||
@@ -88,10 +88,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Mensajes seleccionados ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar mensajes seleccionados",
|
||||
"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": "{modifier}+Click para alternar · Shift+Click rango · Esc limpiar",
|
||||
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
||||
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
||||
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "EN COLA",
|
||||
"messageItem.status.generating": "Generando...",
|
||||
"messageItem.status.sending": "Enviando...",
|
||||
|
||||
@@ -88,10 +88,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Messages sélectionnés ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les messages sélectionnés",
|
||||
"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": "{modifier}+clic pour basculer · Maj+clic pour la plage · Échap effacer",
|
||||
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
||||
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
||||
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "EN FILE",
|
||||
"messageItem.status.generating": "Génération...",
|
||||
"messageItem.status.sending": "Envoi...",
|
||||
|
||||
@@ -88,10 +88,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "選択したメッセージ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "選択したメッセージを削除",
|
||||
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
|
||||
"messageSection.bulkDelete.moreOptionsTitle": "その他のオプション",
|
||||
"messageSection.bulkDelete.selectionModeLabel": "選択",
|
||||
"messageSection.bulkDelete.selectionModeAll": "すべて",
|
||||
"messageSection.bulkDelete.selectionModeTools": "ツールのみ",
|
||||
"messageSection.bulkDelete.selectionHint": "{modifier}+クリックで切り替え · Shift+クリックで範囲選択 · Esc でクリア",
|
||||
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
||||
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
||||
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "待機中",
|
||||
"messageItem.status.generating": "生成中...",
|
||||
"messageItem.status.sending": "送信中...",
|
||||
|
||||
@@ -88,10 +88,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные сообщения ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные сообщения",
|
||||
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
|
||||
"messageSection.bulkDelete.moreOptionsTitle": "Больше настроек",
|
||||
"messageSection.bulkDelete.selectionModeLabel": "Выбор",
|
||||
"messageSection.bulkDelete.selectionModeAll": "Все",
|
||||
"messageSection.bulkDelete.selectionModeTools": "Только инструменты",
|
||||
"messageSection.bulkDelete.selectionHint": "{modifier}+клик переключить · Shift+клик диапазон · Esc очистить",
|
||||
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
||||
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
||||
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
||||
"messageItem.status.generating": "Генерация…",
|
||||
"messageItem.status.sending": "Отправка…",
|
||||
|
||||
@@ -88,10 +88,14 @@ export const messagingMessages = {
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "已选择的消息({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的消息",
|
||||
"messageSection.bulkDelete.selectAllTitle": "全选消息",
|
||||
"messageSection.bulkDelete.moreOptionsTitle": "更多选项",
|
||||
"messageSection.bulkDelete.selectionModeLabel": "选择",
|
||||
"messageSection.bulkDelete.selectionModeAll": "全部",
|
||||
"messageSection.bulkDelete.selectionModeTools": "仅工具",
|
||||
"messageSection.bulkDelete.selectionHint": "{modifier}+点击切换 · Shift+点击范围 · Esc 清除",
|
||||
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
||||
"messageSection.bulkDelete.failedTitle": "删除失败",
|
||||
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
|
||||
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||
"messageItem.status.queued": "排队中",
|
||||
"messageItem.status.generating": "正在生成...",
|
||||
"messageItem.status.sending": "正在发送...",
|
||||
|
||||
70
packages/ui/src/lib/token-utils.ts
Normal file
70
packages/ui/src/lib/token-utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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.
|
||||
*
|
||||
* Skips `filediff` metadata — it contains full before/after file content
|
||||
* and would inflate the character count by 10-100x for large files.
|
||||
*/
|
||||
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
|
||||
if (state) {
|
||||
if (state.input) {
|
||||
try {
|
||||
count += JSON.stringify(state.input).length
|
||||
} catch {}
|
||||
}
|
||||
if (state.output) {
|
||||
if (typeof state.output === "string") {
|
||||
count += state.output.length
|
||||
} else {
|
||||
try {
|
||||
count += JSON.stringify(state.output).length
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (state.metadata) {
|
||||
for (const [key, val] of Object.entries(state.metadata)) {
|
||||
if (key === "filediff") continue
|
||||
if (typeof val === "string") {
|
||||
count += val.length
|
||||
} else if (val && typeof val === "object") {
|
||||
try {
|
||||
count += JSON.stringify(val).length
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray((part as any).content)) {
|
||||
count += (part as any).content.reduce((acc: number, entry: unknown) => {
|
||||
if (typeof entry === "string") return acc + entry.length
|
||||
if (entry && typeof entry === "object") {
|
||||
let entryCount = (String((entry as any).text || "")).length + (String((entry as any).value || "")).length
|
||||
if (Array.isArray((entry as any).content)) {
|
||||
entryCount += (entry as any).content.reduce((innerAcc: number, sub: unknown) => {
|
||||
if (typeof sub === "string") return innerAcc + sub.length
|
||||
return innerAcc + (String((sub as any)?.text || "")).length
|
||||
}, 0)
|
||||
}
|
||||
return acc + entryCount
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -111,6 +111,90 @@
|
||||
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 {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent);
|
||||
|
||||
@@ -357,10 +357,10 @@
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
color: #1a1a2e;
|
||||
background: #ffffff;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-base);
|
||||
padding: 1px 5px;
|
||||
border: 1px solid #1a1a2e;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
|
||||
Reference in New Issue
Block a user