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": "正在发送...",