perf(ui): fix O(n²) in liveSegmentChars and selectedTokenTotal, add i18n + SSR guard
Addresses bot review feedback on commit 224cab6.
## Performance: liveSegmentChars O(n²) → O(n)
The memo had three inner loops scanning all props.segments per unique
messageId. Added a single O(n) pre-pass building a
segmentsByMessageId Map, then replaced all three inner loops with
map lookups. Total complexity: O(n) instead of O(m×n).
File: packages/ui/src/components/message-timeline.tsx
## Performance: selectedTokenTotal O(n²) → O(n)
For each selected messageId, the memo scanned all segments to sum
chars. On "Select all" this was O(selected × segments). Now builds a
charsByMessageId map in one O(n) pass and does O(1) lookups per
selected message. Same pattern as aggregateTokensByMessageId.
File: packages/ui/src/components/message-section.tsx
## SSR guard: resize listener
window.addEventListener("resize", computeBadgeLayout) lacked a
typeof window !== "undefined" guard. Other window usage in the file
was guarded. Wrapped the addEventListener, requestAnimationFrame, and
onCleanup block in the guard.
File: packages/ui/src/components/message-timeline.tsx
## i18n: mirror selectionHint key in 5 locale files
messageSection.bulkDelete.selectionHint was only defined in
en/messaging.ts. Added the key (English string, since Ctrl/Shift/Esc
are universal keyboard labels) to es, fr, ja, ru, and zh-Hans.
Files: packages/ui/src/lib/i18n/messages/{es,fr,ja,ru,zh-Hans}/messaging.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -325,14 +325,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const selectedTokenTotal = createMemo(() => {
|
const selectedTokenTotal = createMemo(() => {
|
||||||
const selected = selectedForDeletion()
|
const selected = selectedForDeletion()
|
||||||
if (selected.size === 0) return 0
|
if (selected.size === 0) return 0
|
||||||
const segments = timelineSegments()
|
// 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.
|
||||||
let total = 0
|
let total = 0
|
||||||
for (const messageId of selected) {
|
for (const messageId of selected) {
|
||||||
let charTotal = 0
|
total += Math.max(Math.round((charsByMessageId[messageId] ?? 0) / 4), 1)
|
||||||
for (const seg of segments) {
|
|
||||||
if (seg.messageId === messageId) charTotal += seg.totalChars
|
|
||||||
}
|
|
||||||
total += Math.max(Math.round(charTotal / 4), 1)
|
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -519,17 +519,19 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isSelectionActive()) {
|
if (isSelectionActive()) {
|
||||||
computeBadgeLayout()
|
computeBadgeLayout()
|
||||||
// Deferred pass: tool segments become visible when selection activates,
|
if (typeof window !== "undefined") {
|
||||||
// but they may need a layout pass before getBoundingClientRect is accurate.
|
// Deferred pass: tool segments become visible when selection activates,
|
||||||
requestAnimationFrame(computeBadgeLayout)
|
// but they may need a layout pass before getBoundingClientRect is accurate.
|
||||||
window.addEventListener("resize", computeBadgeLayout)
|
requestAnimationFrame(computeBadgeLayout)
|
||||||
onCleanup(() => {
|
window.addEventListener("resize", computeBadgeLayout)
|
||||||
window.removeEventListener("resize", computeBadgeLayout)
|
onCleanup(() => {
|
||||||
if (scrollRafId !== null) {
|
window.removeEventListener("resize", computeBadgeLayout)
|
||||||
cancelAnimationFrame(scrollRafId)
|
if (scrollRafId !== null) {
|
||||||
scrollRafId = null
|
cancelAnimationFrame(scrollRafId)
|
||||||
}
|
scrollRafId = null
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -548,26 +550,29 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
const liveSegmentChars = createMemo(() => {
|
const liveSegmentChars = createMemo(() => {
|
||||||
if (!isSelectionActive()) return {} as Record<string, number>
|
if (!isSelectionActive()) return {} as Record<string, number>
|
||||||
const result: Record<string, number> = {}
|
const result: Record<string, number> = {}
|
||||||
const processedMessages = new Set<string>()
|
|
||||||
const resolvedStore = store()
|
const resolvedStore = store()
|
||||||
|
|
||||||
for (const segment of props.segments) {
|
// O(n) pre-pass: group segments by messageId for O(1) lookups below.
|
||||||
if (processedMessages.has(segment.messageId)) continue
|
const segmentsByMessageId = new Map<string, TimelineSegment[]>()
|
||||||
processedMessages.add(segment.messageId)
|
for (const s of props.segments) {
|
||||||
|
let list = segmentsByMessageId.get(s.messageId)
|
||||||
|
if (!list) {
|
||||||
|
list = []
|
||||||
|
segmentsByMessageId.set(s.messageId, list)
|
||||||
|
}
|
||||||
|
list.push(s)
|
||||||
|
}
|
||||||
|
|
||||||
const record = resolvedStore.getMessage(segment.messageId)
|
for (const [messageId, segs] of segmentsByMessageId) {
|
||||||
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
for (const s of props.segments) {
|
for (const s of segs) result[s.id] = s.totalChars
|
||||||
if (s.messageId === segment.messageId) result[s.id] = s.totalChars
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||||
if (!orderedParts?.length) {
|
if (!orderedParts?.length) {
|
||||||
for (const s of props.segments) {
|
for (const s of segs) result[s.id] = s.totalChars
|
||||||
if (s.messageId === segment.messageId) result[s.id] = s.totalChars
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,8 +584,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assign fresh chars to each segment of this message
|
// Assign fresh chars to each segment of this message
|
||||||
for (const s of props.segments) {
|
for (const s of segs) {
|
||||||
if (s.messageId !== segment.messageId) continue
|
|
||||||
const ids = [...(s.partIds ?? []), ...(s.toolPartIds ?? [])]
|
const ids = [...(s.partIds ?? []), ...(s.toolPartIds ?? [])]
|
||||||
let chars = 0
|
let chars = 0
|
||||||
for (const pid of ids) chars += partChars.get(pid) ?? 0
|
for (const pid of ids) chars += partChars.get(pid) ?? 0
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const messagingMessages = {
|
|||||||
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
||||||
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
||||||
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
|
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
|
||||||
|
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||||
"messageItem.status.queued": "EN COLA",
|
"messageItem.status.queued": "EN COLA",
|
||||||
"messageItem.status.generating": "Generando...",
|
"messageItem.status.generating": "Generando...",
|
||||||
"messageItem.status.sending": "Enviando...",
|
"messageItem.status.sending": "Enviando...",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const messagingMessages = {
|
|||||||
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
||||||
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
||||||
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
|
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
|
||||||
|
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||||
"messageItem.status.queued": "EN FILE",
|
"messageItem.status.queued": "EN FILE",
|
||||||
"messageItem.status.generating": "Génération...",
|
"messageItem.status.generating": "Génération...",
|
||||||
"messageItem.status.sending": "Envoi...",
|
"messageItem.status.sending": "Envoi...",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const messagingMessages = {
|
|||||||
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
||||||
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
||||||
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
|
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
|
||||||
|
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||||
"messageItem.status.queued": "待機中",
|
"messageItem.status.queued": "待機中",
|
||||||
"messageItem.status.generating": "生成中...",
|
"messageItem.status.generating": "生成中...",
|
||||||
"messageItem.status.sending": "送信中...",
|
"messageItem.status.sending": "送信中...",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const messagingMessages = {
|
|||||||
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
||||||
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
||||||
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
|
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
|
||||||
|
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||||
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
||||||
"messageItem.status.generating": "Генерация…",
|
"messageItem.status.generating": "Генерация…",
|
||||||
"messageItem.status.sending": "Отправка…",
|
"messageItem.status.sending": "Отправка…",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const messagingMessages = {
|
|||||||
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
||||||
"messageSection.bulkDelete.failedTitle": "删除失败",
|
"messageSection.bulkDelete.failedTitle": "删除失败",
|
||||||
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
|
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
|
||||||
|
"messageSection.bulkDelete.selectionHint": "Ctrl+Click toggle \u00b7 Shift+Click range \u00b7 Esc clear",
|
||||||
"messageItem.status.queued": "排队中",
|
"messageItem.status.queued": "排队中",
|
||||||
"messageItem.status.generating": "正在生成...",
|
"messageItem.status.generating": "正在生成...",
|
||||||
"messageItem.status.sending": "正在发送...",
|
"messageItem.status.sending": "正在发送...",
|
||||||
|
|||||||
Reference in New Issue
Block a user