From 2c27fc53adb5e68996d3cd855678a7639b7c45c8 Mon Sep 17 00:00:00 2001 From: VooDisss Date: Mon, 2 Mar 2026 17:46:51 +0200 Subject: [PATCH] =?UTF-8?q?perf(ui):=20fix=20O(n=C2=B2)=20in=20liveSegment?= =?UTF-8?q?Chars=20and=20selectedTokenTotal,=20add=20i18n=20+=20SSR=20guar?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/src/components/message-section.tsx | 13 ++--- .../ui/src/components/message-timeline.tsx | 52 ++++++++++--------- .../ui/src/lib/i18n/messages/es/messaging.ts | 1 + .../ui/src/lib/i18n/messages/fr/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ja/messaging.ts | 1 + .../ui/src/lib/i18n/messages/ru/messaging.ts | 1 + .../lib/i18n/messages/zh-Hans/messaging.ts | 1 + 7 files changed, 40 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 300d9872..38d828ce 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -325,14 +325,15 @@ export default function MessageSection(props: MessageSectionProps) { const selectedTokenTotal = createMemo(() => { const selected = selectedForDeletion() if (selected.size === 0) return 0 - const segments = timelineSegments() + // O(n) pre-pass: aggregate chars by messageId once. + const charsByMessageId: Record = {} + for (const seg of timelineSegments()) { + charsByMessageId[seg.messageId] = (charsByMessageId[seg.messageId] ?? 0) + seg.totalChars + } + // O(selected.size) lookup pass. let total = 0 for (const messageId of selected) { - let charTotal = 0 - for (const seg of segments) { - if (seg.messageId === messageId) charTotal += seg.totalChars - } - total += Math.max(Math.round(charTotal / 4), 1) + total += Math.max(Math.round((charsByMessageId[messageId] ?? 0) / 4), 1) } return total }) diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index e7af2ef8..b578fc39 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -519,17 +519,19 @@ const MessageTimeline: Component = (props) => { createEffect(() => { if (isSelectionActive()) { computeBadgeLayout() - // Deferred pass: tool segments become visible when selection activates, - // but they may need a layout pass before getBoundingClientRect is accurate. - requestAnimationFrame(computeBadgeLayout) - window.addEventListener("resize", computeBadgeLayout) - onCleanup(() => { - window.removeEventListener("resize", computeBadgeLayout) - if (scrollRafId !== null) { - cancelAnimationFrame(scrollRafId) - scrollRafId = null - } - }) + if (typeof window !== "undefined") { + // Deferred pass: tool segments become visible when selection activates, + // but they may need a layout pass before getBoundingClientRect is accurate. + requestAnimationFrame(computeBadgeLayout) + window.addEventListener("resize", computeBadgeLayout) + onCleanup(() => { + window.removeEventListener("resize", computeBadgeLayout) + if (scrollRafId !== null) { + cancelAnimationFrame(scrollRafId) + scrollRafId = null + } + }) + } } }) @@ -548,26 +550,29 @@ const MessageTimeline: Component = (props) => { const liveSegmentChars = createMemo(() => { if (!isSelectionActive()) return {} as Record const result: Record = {} - const processedMessages = new Set() const resolvedStore = store() - for (const segment of props.segments) { - if (processedMessages.has(segment.messageId)) continue - processedMessages.add(segment.messageId) + // O(n) pre-pass: group segments by messageId for O(1) lookups below. + const segmentsByMessageId = new Map() + 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) { - for (const s of props.segments) { - if (s.messageId === segment.messageId) result[s.id] = s.totalChars - } + for (const s of segs) result[s.id] = s.totalChars continue } const { orderedParts } = buildRecordDisplayData(props.instanceId, record) if (!orderedParts?.length) { - for (const s of props.segments) { - if (s.messageId === segment.messageId) result[s.id] = s.totalChars - } + for (const s of segs) result[s.id] = s.totalChars continue } @@ -579,8 +584,7 @@ const MessageTimeline: Component = (props) => { } // Assign fresh chars to each segment of this message - for (const s of props.segments) { - if (s.messageId !== segment.messageId) continue + for (const s of segs) { const ids = [...(s.partIds ?? []), ...(s.toolPartIds ?? [])] let chars = 0 for (const pid of ids) chars += partChars.get(pid) ?? 0 diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index da4be35b..71164f45 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -91,6 +91,7 @@ export const messagingMessages = { "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...", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index 8f4c1af0..af806804 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -91,6 +91,7 @@ export const messagingMessages = { "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...", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index f07f9789..e7106385 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -91,6 +91,7 @@ export const messagingMessages = { "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": "送信中...", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index 71430754..f9822382 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -91,6 +91,7 @@ export const messagingMessages = { "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": "Отправка…", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index 71f5c0f8..9b582bf6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -91,6 +91,7 @@ export const messagingMessages = { "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": "正在发送...",