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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user