diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx index 2d5a382b..de5b8890 100644 --- a/packages/ui/src/components/message-block-list.tsx +++ b/packages/ui/src/components/message-block-list.tsx @@ -27,6 +27,8 @@ interface MessageBlockListProps { onContentRendered?: () => void deleteHover?: Accessor onDeleteHoverChange?: (state: DeleteHoverState) => void + selectedMessageIds?: Accessor> + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void setBottomSentinel: (element: HTMLDivElement | null) => void suspendMeasurements?: () => boolean } @@ -57,6 +59,8 @@ export default function MessageBlockList(props: MessageBlockListProps) { showUsageMetrics={props.showUsageMetrics} deleteHover={props.deleteHover} onDeleteHoverChange={props.onDeleteHoverChange} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} onRevert={props.onRevert} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onFork={props.onFork} diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index bc125afe..1e476817 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import MessageItem from "./message-item" import ToolCall from "./tool-call" @@ -208,6 +208,8 @@ interface MessageContentItemProps { onContentRendered?: () => void showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function isSupportedPartType(part: unknown): boolean { @@ -296,6 +298,8 @@ function MessageContentItem(props: MessageContentItemProps) { showAgentMeta={showAgentMeta()} showDeleteMessage={props.showDeleteMessage} onDeleteHoverChange={props.onDeleteHoverChange} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} onRevert={props.onRevert} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onFork={props.onFork} @@ -316,6 +320,8 @@ interface ToolCallItemProps { showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function ToolCallItem(props: ToolCallItemProps) { @@ -323,6 +329,8 @@ function ToolCallItem(props: ToolCallItemProps) { const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) + const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const partEntry = createMemo(() => record()?.parts?.[props.partId]) @@ -403,6 +411,24 @@ function ToolCallItem(props: ToolCallItemProps) {
+ + { + event.stopPropagation() + }} + onChange={(event) => { + event.stopPropagation() + const next = Boolean((event.currentTarget as HTMLInputElement).checked) + props.onToggleSelectedMessage?.(props.messageId, next) + }} + aria-label={t("messageItem.selection.checkboxAriaLabel")} + title={t("messageItem.selection.checkboxAriaLabel")} + /> + + {TOOL_ICON} {t("messageBlock.tool.header")} {toolName() || t("messageBlock.tool.unknown")} @@ -516,6 +542,8 @@ interface MessageBlockProps { showUsageMetrics: () => boolean deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onRevert?: (messageId: string) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void @@ -531,6 +559,11 @@ export default function MessageBlock(props: MessageBlockProps) { const isDeleteMessageHovered = () => { const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) + const selected = props.selectedMessageIds?.() ?? new Set() + if (selected.has(props.messageId)) { + return true + } + if (hover.kind === "message") { return hover.messageId === props.messageId } @@ -755,6 +788,8 @@ export default function MessageBlock(props: MessageBlockProps) { onDeleteHoverChange={props.onDeleteHoverChange} onRevert={props.onRevert} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} onFork={props.onFork} onContentRendered={props.onContentRendered} /> @@ -773,6 +808,8 @@ export default function MessageBlock(props: MessageBlockProps) { showDeleteMessage={index() === 0} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} onContentRendered={props.onContentRendered} />
@@ -791,6 +828,8 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={props.messageId} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} /> @@ -806,6 +845,8 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={props.messageId} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} /> @@ -819,6 +860,8 @@ export default function MessageBlock(props: MessageBlockProps) { showDeleteMessage={index() === 0} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} /> @@ -833,6 +876,8 @@ export default function MessageBlock(props: MessageBlockProps) { showDeleteMessage={index() === 0} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} /> @@ -857,6 +902,8 @@ interface StepCardProps { messageId?: string onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } interface CompactionCardProps { @@ -869,12 +916,15 @@ interface CompactionCardProps { showDeleteMessage?: boolean onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } function CompactionCard(props: CompactionCardProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isAuto = () => Boolean((props.part as any)?.auto) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) @@ -956,6 +1006,24 @@ function CompactionCard(props: CompactionCardProps) {
+ + { + event.stopPropagation() + }} + onChange={(event) => { + event.stopPropagation() + const next = Boolean((event.currentTarget as HTMLInputElement).checked) + props.onToggleSelectedMessage?.(props.messageId, next) + }} + aria-label={t("messageItem.selection.checkboxAriaLabel")} + title={t("messageItem.selection.checkboxAriaLabel")} + /> + +
@@ -967,6 +1035,7 @@ function StepCard(props: StepCardProps) { const { t } = useI18n() const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId)) const timestamp = () => { const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const date = new Date(value) @@ -1078,6 +1147,24 @@ function StepCard(props: StepCardProps) { } return (
+ + { + event.stopPropagation() + }} + onChange={(event) => { + event.stopPropagation() + const next = Boolean((event.currentTarget as HTMLInputElement).checked) + props.onToggleSelectedMessage?.(props.messageId!, next) + }} + aria-label={t("messageItem.selection.checkboxAriaLabel")} + title={t("messageItem.selection.checkboxAriaLabel")} + /> + +
-
+
(actionsEl = el)}>
+ +
+ + + {(value) => ( + {t("messageBlock.step.agentLabel", { agent: value() })} + )} + + + {(value) => ( + {t("messageBlock.step.modelLabel", { model: value() })} + )} + + +
+
+
diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 4aec64ca..0253235b 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -1,4 +1,4 @@ -import { For, Show, createSignal } from "solid-js" +import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import { partHasRenderableText } from "../types/message" @@ -27,6 +27,8 @@ interface MessageItemProps { isQueued?: boolean parts: ClientPart[] onRevert?: (messageId: string) => void + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise onFork?: (messageId?: string) => void showAgentMeta?: boolean @@ -41,6 +43,46 @@ export default function MessageItem(props: MessageItemProps) { const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id)) + + let topRowEl: HTMLDivElement | undefined + let actionsEl: HTMLDivElement | undefined + let speakerPrimaryEl: HTMLDivElement | undefined + let metaMeasureEl: HTMLSpanElement | undefined + const [showMetaInline, setShowMetaInline] = createSignal(true) + + const metaText = () => agentMeta() + + const updateMetaLayout = () => { + const text = metaText() + if (!text) return + if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return + + const rowWidth = topRowEl.getBoundingClientRect().width + const actionsWidth = actionsEl.getBoundingClientRect().width + const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width + const metaWidth = metaMeasureEl.getBoundingClientRect().width + + // Allow for the flex gap between left and actions. + const availableLeft = Math.max(0, rowWidth - actionsWidth - 12) + setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft) + } + + createEffect(() => { + const text = metaText() + if (!text || typeof ResizeObserver === "undefined") { + setShowMetaInline(true) + return + } + + updateMetaLayout() + const observer = new ResizeObserver(() => updateMetaLayout()) + if (topRowEl) observer.observe(topRowEl) + if (actionsEl) observer.observe(actionsEl) + if (speakerPrimaryEl) observer.observe(speakerPrimaryEl) + onCleanup(() => observer.disconnect()) + }) + const isUser = () => props.record.role === "user" const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt @@ -284,14 +326,47 @@ export default function MessageItem(props: MessageItemProps) { return (
-
-
- - {speakerLabel()} - +
(topRowEl = el)}> +
+
(speakerPrimaryEl = el)}> + + { + event.stopPropagation() + }} + onChange={(event) => { + event.stopPropagation() + const next = Boolean((event.currentTarget as HTMLInputElement).checked) + props.onToggleSelectedMessage?.(props.record.id, next) + }} + aria-label={t("messageItem.selection.checkboxAriaLabel")} + title={t("messageItem.selection.checkboxAriaLabel")} + /> + + + + {speakerLabel()} + +
+ + + {metaText()} + + + + (metaMeasureEl = el)} + class="message-agent-meta-inline message-agent-meta-inline--measure" + > + {metaText()} + +
-
+
(actionsEl = el)}>
- - {(meta) => ( -
- {meta()} -
- )} + +
+ {metaText()} +
diff --git a/packages/ui/src/components/message-preview.tsx b/packages/ui/src/components/message-preview.tsx index 9c1a37ba..fbac99f9 100644 --- a/packages/ui/src/components/message-preview.tsx +++ b/packages/ui/src/components/message-preview.tsx @@ -11,6 +11,8 @@ interface MessagePreviewProps { deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } const MessagePreview: Component = (props) => { @@ -31,6 +33,8 @@ const MessagePreview: Component = (props) => { deleteHover={props.deleteHover} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} + onToggleSelectedMessage={props.onToggleSelectedMessage} />
) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index e062a219..8ebff4f1 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,4 +1,5 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" +import { CheckSquare, Trash, X } from "lucide-solid" import Kbd from "./kbd" import MessageBlockList, { getMessageAnchorId } from "./message-block-list" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" @@ -9,6 +10,8 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { useI18n } from "../lib/i18n" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" +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" @@ -149,6 +152,61 @@ export default function MessageSection(props: MessageSectionProps) { const [activeMessageId, setActiveMessageId] = createSignal(null) const [deleteHover, setDeleteHover] = createSignal({ kind: "none" }) + + const [selectedForDeletion, setSelectedForDeletion] = createSignal>(new Set()) + const isDeleteMode = createMemo(() => selectedForDeletion().size > 0) + const selectedDeleteCount = createMemo(() => selectedForDeletion().size) + + const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId) + + const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => { + if (!messageId) return + setSelectedForDeletion((prev) => { + const next = new Set(prev) + if (selected) { + next.add(messageId) + } else { + next.delete(messageId) + } + return next + }) + } + + const clearDeleteMode = () => { + setSelectedForDeletion(new Set()) + setDeleteHover({ kind: "none" }) + } + + const selectAllForDeletion = () => { + setSelectedForDeletion(new Set(messageIds())) + } + + const deleteSelectedMessages = async () => { + const selected = selectedForDeletion() + if (selected.size === 0) return + + const idsInSessionOrder = messageIds() + const toDelete: string[] = [] + for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) { + const id = idsInSessionOrder[idx] + if (selected.has(id)) { + toDelete.push(id) + } + } + + try { + for (const messageId of toDelete) { + await deleteMessage(props.instanceId, props.sessionId, messageId) + } + clearDeleteMode() + } catch (error) { + showAlertDialog(t("messageSection.bulkDelete.failedMessage"), { + title: t("messageSection.bulkDelete.failedTitle"), + detail: error instanceof Error ? error.message : String(error), + variant: "error", + }) + } + } const changeToken = createMemo(() => String(sessionRevision())) const isActive = createMemo(() => props.isActive !== false) @@ -171,6 +229,7 @@ export default function MessageSection(props: MessageSectionProps) { const [autoScroll, setAutoScroll] = createSignal(true) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) + const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) @@ -855,7 +914,10 @@ export default function MessageSection(props: MessageSectionProps) { return (
-
+
+ + + +
diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 7549a926..61d42793 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -34,6 +34,8 @@ interface MessageTimelineProps { deleteHover?: () => DeleteHoverState onDeleteHoverChange?: (state: DeleteHoverState) => void onDeleteMessagesUpTo?: (messageId: string) => void | Promise + selectedMessageIds?: () => Set + onToggleSelectedMessage?: (messageId: string, selected: boolean) => void } const MAX_TOOLTIP_LENGTH = 220 @@ -417,6 +419,10 @@ const MessageTimeline: Component = (props) => { const isDeleteHovered = () => { const hover = deleteHover() as DeleteHoverState + const selected = props.selectedMessageIds?.() ?? new Set() + if (selected.has(segment.messageId)) { + return true + } if (hover.kind === "message") { return hover.messageId === segment.messageId } @@ -502,6 +508,7 @@ const MessageTimeline: Component = (props) => { deleteHover={props.deleteHover} onDeleteHoverChange={props.onDeleteHoverChange} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} + selectedMessageIds={props.selectedMessageIds} />
) diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index a6dc2c6c..2ea550cc 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "Deleting...", "messageItem.actions.deleteMessageFailedTitle": "Delete failed", "messageItem.actions.deleteMessageFailedMessage": "Failed to delete message", + + "messageItem.selection.checkboxAriaLabel": "Select message for deletion", + + "messageSection.bulkDelete.toolbarAriaLabel": "Selected messages ({count})", + "messageSection.bulkDelete.deleteSelectedTitle": "Delete selected messages", + "messageSection.bulkDelete.selectAllTitle": "Select all messages", + "messageSection.bulkDelete.cancelTitle": "Cancel selection", + "messageSection.bulkDelete.failedTitle": "Delete failed", + "messageSection.bulkDelete.failedMessage": "Failed to delete selected messages", "messageItem.status.queued": "QUEUED", "messageItem.status.generating": "Generating...", "messageItem.status.sending": "Sending...", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index e617123d..da4be35b 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "Eliminando...", "messageItem.actions.deleteMessageFailedTitle": "Error al eliminar", "messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje", + + "messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar", + + "messageSection.bulkDelete.toolbarAriaLabel": "Mensajes seleccionados ({count})", + "messageSection.bulkDelete.deleteSelectedTitle": "Eliminar mensajes seleccionados", + "messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes", + "messageSection.bulkDelete.cancelTitle": "Cancelar selección", + "messageSection.bulkDelete.failedTitle": "Error al eliminar", + "messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados", "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 83be6cd1..8f4c1af0 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "Suppression...", "messageItem.actions.deleteMessageFailedTitle": "Échec de suppression", "messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message", + + "messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression", + + "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.cancelTitle": "Annuler la sélection", + "messageSection.bulkDelete.failedTitle": "Échec de suppression", + "messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés", "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 51cabb9d..f07f9789 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "削除中...", "messageItem.actions.deleteMessageFailedTitle": "削除に失敗しました", "messageItem.actions.deleteMessageFailedMessage": "メッセージの削除に失敗しました", + + "messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択", + + "messageSection.bulkDelete.toolbarAriaLabel": "選択したメッセージ({count})", + "messageSection.bulkDelete.deleteSelectedTitle": "選択したメッセージを削除", + "messageSection.bulkDelete.selectAllTitle": "すべて選択", + "messageSection.bulkDelete.cancelTitle": "選択をキャンセル", + "messageSection.bulkDelete.failedTitle": "削除に失敗しました", + "messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました", "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 a6d47780..71430754 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "Удаление...", "messageItem.actions.deleteMessageFailedTitle": "Ошибка удаления", "messageItem.actions.deleteMessageFailedMessage": "Не удалось удалить сообщение", + + "messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления", + + "messageSection.bulkDelete.toolbarAriaLabel": "Выбранные сообщения ({count})", + "messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные сообщения", + "messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения", + "messageSection.bulkDelete.cancelTitle": "Отменить выбор", + "messageSection.bulkDelete.failedTitle": "Ошибка удаления", + "messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения", "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 3cf2fa24..71f5c0f8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -82,6 +82,15 @@ export const messagingMessages = { "messageItem.actions.deletingMessage": "正在删除...", "messageItem.actions.deleteMessageFailedTitle": "删除失败", "messageItem.actions.deleteMessageFailedMessage": "无法删除消息", + + "messageItem.selection.checkboxAriaLabel": "选择要删除的消息", + + "messageSection.bulkDelete.toolbarAriaLabel": "已选择的消息({count})", + "messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的消息", + "messageSection.bulkDelete.selectAllTitle": "全选消息", + "messageSection.bulkDelete.cancelTitle": "取消选择", + "messageSection.bulkDelete.failedTitle": "删除失败", + "messageSection.bulkDelete.failedMessage": "无法删除已选择的消息", "messageItem.status.queued": "排队中", "messageItem.status.generating": "正在生成...", "messageItem.status.sending": "正在发送...", diff --git a/packages/ui/src/styles/messaging.css b/packages/ui/src/styles/messaging.css index 0d7cf1ff..f6c9db0d 100644 --- a/packages/ui/src/styles/messaging.css +++ b/packages/ui/src/styles/messaging.css @@ -2,6 +2,7 @@ @import "./messaging/prompt-input.css"; @import "./messaging/message-section.css"; @import "./messaging/message-block-list.css"; +@import "./messaging/message-selection.css"; @import "./messaging/delete-overlays.css"; @import "./messaging/message-timeline.css"; @import "./messaging/tool-call.css"; diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 5f45939a..14bd58da 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -8,7 +8,8 @@ } .message-item-header { - @apply flex flex-col gap-0.5; + @apply flex flex-col; + gap: 0.25rem; } .message-item-header-row { @@ -19,12 +20,58 @@ @apply flex justify-between items-start gap-2.5; } +.message-item-header-row--meta { + @apply w-full; +} + +.message-header-left { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1 1 auto; + min-width: 0; +} + .message-item-header-row--bottom { @apply flex items-start; } .message-speaker { - @apply flex flex-col gap-0.5 text-xs; + /* Allow agent meta to wrap to a second row with comfortable spacing. */ + @apply flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs; + flex: 1 1 auto; + min-width: 0; +} + +.message-speaker-primary { + @apply inline-flex items-center; + white-space: nowrap; + flex: 0 0 auto; +} + +.message-agent-meta-inline { + @apply text-[11px] font-medium; + color: var(--message-assistant-border); + white-space: nowrap; + flex: 0 0 auto; + line-height: 1.1; +} + +.message-agent-meta-inline--measure { + position: fixed; + left: -9999px; + top: -9999px; + visibility: hidden; + pointer-events: none; + white-space: nowrap; +} + +.message-agent-meta-block { + @apply text-[11px] font-medium; + color: var(--message-assistant-border); + overflow-wrap: anywhere; + word-break: break-word; + line-height: 1.15; } .message-speaker-label { @@ -46,19 +93,19 @@ .message-item-actions { @apply flex items-center gap-2; + flex: 0 0 auto; } .message-action-group { - @apply flex items-center gap-2; + @apply flex items-center gap-0; } .message-action-button { - @apply bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6; + @apply bg-transparent border-0 text-[var(--text-muted)] cursor-pointer px-2 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6; } .message-action-button:hover { background-color: var(--surface-hover); - border-color: var(--accent-primary); color: var(--accent-primary); } @@ -296,6 +343,12 @@ color: var(--message-assistant-border); } +/* Keep reasoning meta as a single unit so it drops to the next line when needed. */ +.message-reasoning-label .message-step-meta-inline { + flex-wrap: nowrap; + white-space: nowrap; +} + .message-step-reason { @apply text-[11px] font-medium; @@ -320,7 +373,7 @@ .message-reasoning-header { display: flex; - align-items: stretch; + align-items: flex-start; justify-content: space-between; gap: 0.5rem; transition: background-color 0.2s ease, box-shadow 0.2s ease; @@ -365,11 +418,36 @@ } .message-reasoning-label { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.5rem; font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--message-assistant-border); } +.message-reasoning-label-primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; + flex: 0 0 auto; +} + +.message-step-meta-inline--measure { + position: fixed; + left: -9999px; + top: -9999px; + visibility: hidden; + pointer-events: none; + white-space: nowrap; +} + +.message-reasoning-meta-row { + padding: 0 0.6rem 0.15rem 0.6rem; +} + .message-reasoning-meta { display: inline-flex; align-items: center; diff --git a/packages/ui/src/styles/messaging/message-selection.css b/packages/ui/src/styles/messaging/message-selection.css new file mode 100644 index 00000000..1d6cd807 --- /dev/null +++ b/packages/ui/src/styles/messaging/message-selection.css @@ -0,0 +1,84 @@ +/* Message multi-select delete mode UI. */ + +.message-select-checkbox { + width: 14px; + height: 14px; + margin-right: 0.5rem; + cursor: pointer; + accent-color: var(--status-error); + flex: 0 0 auto; +} + +.message-delete-mode-toolbar { + position: absolute; + right: 12px; + bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + background: color-mix(in oklab, var(--surface-secondary) 92%, var(--status-error-bg)); + border: 1px solid var(--border-base); + border-radius: 12px; + z-index: 50; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); +} + +/* Avoid covering the scroll-to-top/bottom floating buttons. */ +.message-layout[data-scroll-buttons="1"] .message-delete-mode-toolbar { + bottom: 4.25rem; +} + +.message-layout[data-scroll-buttons="2"] .message-delete-mode-toolbar { + bottom: 7.5rem; +} + +/* When timeline is visible, pin the toolbar to the stream edge. */ +.message-layout--with-timeline .message-delete-mode-toolbar { + right: calc(64px + 12px); +} + +@media (max-width: 720px) { + .message-layout--with-timeline .message-delete-mode-toolbar { + right: calc(40px + 12px); + } +} + +.message-delete-mode-count { + min-width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + color: var(--text-primary); + background: var(--surface-secondary); + border: 1px solid var(--border-base); +} + +.message-delete-mode-button { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-base); + border-radius: 10px; + color: var(--text-muted); + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.message-delete-mode-button:hover { + background-color: var(--surface-hover); + border-color: var(--status-error); + color: var(--status-error); +} + +.message-delete-mode-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent); +}