From 0f9c99e3bdc20708d0ab916ecef5da93cdf6ac5e Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 25 Feb 2026 23:32:32 +0000 Subject: [PATCH] feat(ui): mirror delete hover overlay in timeline --- .../ui/src/components/message-block-list.tsx | 5 ++ packages/ui/src/components/message-block.tsx | 81 ++++++++++++------- packages/ui/src/components/message-item.tsx | 33 +++++--- .../ui/src/components/message-section.tsx | 6 ++ .../ui/src/components/message-timeline.tsx | 63 ++++++++++++--- .../src/styles/messaging/delete-overlays.css | 4 +- .../src/styles/messaging/message-timeline.css | 31 +++++++ packages/ui/src/types/delete-hover.ts | 4 + 8 files changed, 176 insertions(+), 51 deletions(-) create mode 100644 packages/ui/src/types/delete-hover.ts diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx index 3db083cc..8b2288b4 100644 --- a/packages/ui/src/components/message-block-list.tsx +++ b/packages/ui/src/components/message-block-list.tsx @@ -2,6 +2,7 @@ import { Index, type Accessor } from "solid-js" import VirtualItem from "./virtual-item" import MessageBlock from "./message-block" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" +import type { DeleteHoverState } from "../types/delete-hover" export function getMessageAnchorId(messageId: string) { return `message-anchor-${messageId}` @@ -23,6 +24,8 @@ interface MessageBlockListProps { onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void onContentRendered?: () => void + deleteHover?: Accessor + onDeleteHoverChange?: (state: DeleteHoverState) => void setBottomSentinel: (element: HTMLDivElement | null) => void suspendMeasurements?: () => boolean } @@ -51,6 +54,8 @@ export default function MessageBlockList(props: MessageBlockListProps) { showThinking={props.showThinking} thinkingDefaultExpanded={props.thinkingDefaultExpanded} showUsageMetrics={props.showUsageMetrics} + deleteHover={props.deleteHover} + onDeleteHoverChange={props.onDeleteHoverChange} onRevert={props.onRevert} onFork={props.onFork} onContentRendered={props.onContentRendered} diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index a0410eae..7dc66e17 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -15,6 +15,7 @@ import { showAlertDialog } from "../stores/alerts" import { deleteMessagePart } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions" import { useI18n } from "../lib/i18n" +import type { DeleteHoverState } from "../types/delete-hover" const TOOL_ICON = "🔧" const USER_BORDER_COLOR = "var(--message-user-border)" @@ -198,7 +199,7 @@ interface MessageContentItemProps { onFork?: (messageId?: string) => void onContentRendered?: () => void showDeleteMessage?: boolean - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } function isSupportedPartType(part: unknown): boolean { @@ -286,7 +287,7 @@ function MessageContentItem(props: MessageContentItemProps) { isQueued={isQueued()} showAgentMeta={showAgentMeta()} showDeleteMessage={props.showDeleteMessage} - onDeleteMessageHoverChange={props.onDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} onRevert={props.onRevert} onFork={props.onFork} onContentRendered={props.onContentRendered} @@ -304,7 +305,7 @@ interface ToolCallItemProps { partId: string onContentRendered?: () => void showDeleteMessage?: boolean - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } function ToolCallItem(props: ToolCallItemProps) { @@ -430,8 +431,14 @@ function ToolCallItem(props: ToolCallItemProps) { type="button" disabled={deleteDisabled()} onClick={handleDeleteToolPart} - onMouseEnter={() => setHoverDeletePart(true)} - onMouseLeave={() => setHoverDeletePart(false)} + onMouseEnter={() => { + setHoverDeletePart(true) + props.onDeleteHoverChange?.({ kind: "part", messageId: props.messageId, partId: props.partId, partType: "tool" }) + }} + onMouseLeave={() => { + setHoverDeletePart(false) + props.onDeleteHoverChange?.({ kind: "none" }) + }} title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} > @@ -444,8 +451,8 @@ function ToolCallItem(props: ToolCallItemProps) { type="button" disabled={deletingMessage()} onClick={handleDeleteMessage} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > @@ -517,6 +524,8 @@ interface MessageBlockProps { showThinking: () => boolean thinkingDefaultExpanded: () => boolean showUsageMetrics: () => boolean + deleteHover?: () => DeleteHoverState + onDeleteHoverChange?: (state: DeleteHoverState) => void onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void onContentRendered?: () => void @@ -527,10 +536,10 @@ export default function MessageBlock(props: MessageBlockProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) - const [deleteMessageHovered, setDeleteMessageHovered] = createSignal(false) - const handleDeleteMessageHoverChange = (hovered: boolean) => { - setDeleteMessageHovered(hovered) + const isDeleteMessageHovered = () => { + const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState) + return hover.kind === "message" && hover.messageId === props.messageId } const block = createMemo(() => { @@ -723,7 +732,7 @@ export default function MessageBlock(props: MessageBlockProps) {
{(item, index) => ( @@ -738,7 +747,7 @@ export default function MessageBlock(props: MessageBlockProps) { messageIndex={props.messageIndex} lastAssistantIndex={props.lastAssistantIndex} showDeleteMessage={index() === 0} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} onRevert={props.onRevert} onFork={props.onFork} onContentRendered={props.onContentRendered} @@ -756,7 +765,7 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={toolItem.messageId} partId={toolItem.partId} showDeleteMessage={index() === 0} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} onContentRendered={props.onContentRendered} />
@@ -773,7 +782,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} /> @@ -787,7 +796,7 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} /> @@ -800,7 +809,7 @@ export default function MessageBlock(props: MessageBlockProps) { messageId={(item as CompactionDisplayItem).messageId} partId={(item as CompactionDisplayItem).partId} showDeleteMessage={index() === 0} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} /> @@ -814,7 +823,7 @@ export default function MessageBlock(props: MessageBlockProps) { showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} showDeleteMessage={index() === 0} - onDeleteMessageHoverChange={handleDeleteMessageHoverChange} + onDeleteHoverChange={props.onDeleteHoverChange} /> @@ -837,7 +846,7 @@ interface StepCardProps { instanceId?: string sessionId?: string messageId?: string - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } interface CompactionCardProps { @@ -849,7 +858,7 @@ interface CompactionCardProps { messageId: string partId: string showDeleteMessage?: boolean - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } function CompactionCard(props: CompactionCardProps) { @@ -920,8 +929,8 @@ function CompactionCard(props: CompactionCardProps) { class="tool-call-header-button" disabled={!canDeleteMessage()} onClick={handleDeleteMessage} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > @@ -934,8 +943,14 @@ function CompactionCard(props: CompactionCardProps) { class="tool-call-header-button" disabled={!canDelete()} onClick={handleDelete} - onMouseEnter={() => setHoverDeletePart(true)} - onMouseLeave={() => setHoverDeletePart(false)} + onMouseEnter={() => { + setHoverDeletePart(true) + props.onDeleteHoverChange?.({ kind: "part", messageId: props.messageId, partId: props.partId, partType: "compaction" }) + }} + onMouseLeave={() => { + setHoverDeletePart(false) + props.onDeleteHoverChange?.({ kind: "none" }) + }} title={t("messagePart.actions.deleteTitle")} aria-label={t("messagePart.actions.deleteTitle")} > @@ -1056,8 +1071,8 @@ function StepCard(props: StepCardProps) { class="message-action-button absolute right-2 top-1/2 -translate-y-1/2" disabled={!canDeleteMessage()} onClick={handleDeleteMessage} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > @@ -1105,7 +1120,7 @@ interface ReasoningCardProps { showAgentMeta?: boolean defaultExpanded?: boolean showDeleteMessage?: boolean - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } function ReasoningCard(props: ReasoningCardProps) { @@ -1274,8 +1289,14 @@ function ReasoningCard(props: ReasoningCardProps) { class="message-action-button" onClick={handleDelete} disabled={!canDelete()} - onMouseEnter={() => setHoverDeletePart(true)} - onMouseLeave={() => setHoverDeletePart(false)} + onMouseEnter={() => { + setHoverDeletePart(true) + props.onDeleteHoverChange?.({ kind: "part", messageId: props.messageId, partId: props.partId, partType: "reasoning" }) + }} + onMouseLeave={() => { + setHoverDeletePart(false) + props.onDeleteHoverChange?.({ kind: "none" }) + }} aria-label={t("messagePart.actions.deleteTitle")} title={t("messagePart.actions.deleteTitle")} > @@ -1289,8 +1310,8 @@ function ReasoningCard(props: ReasoningCardProps) { class="message-action-button" onClick={handleDeleteMessage} disabled={!canDeleteMessage()} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index d6be6a7f..2c3d4c6f 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -9,6 +9,7 @@ import { useI18n } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import { isTauriHost } from "../lib/runtime-env" +import type { DeleteHoverState } from "../types/delete-hover" interface MessageItemProps { record: MessageRecord @@ -22,7 +23,7 @@ interface MessageItemProps { showAgentMeta?: boolean onContentRendered?: () => void showDeleteMessage?: boolean - onDeleteMessageHoverChange?: (hovered: boolean) => void + onDeleteHoverChange?: (state: DeleteHoverState) => void } export default function MessageItem(props: MessageItemProps) { @@ -354,8 +355,8 @@ export default function MessageItem(props: MessageItemProps) { class="message-action-button" onClick={handleDeleteMessage} disabled={deletingMessage()} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > @@ -381,8 +382,14 @@ export default function MessageItem(props: MessageItemProps) { class="message-action-button" onClick={() => void handleDeletePart(partId())} disabled={isDeletingPart(partId())} - onMouseEnter={() => setHoveredDeletePartId(partId())} - onMouseLeave={() => setHoveredDeletePartId(null)} + onMouseEnter={() => { + setHoveredDeletePartId(partId()) + props.onDeleteHoverChange?.({ kind: "part", messageId: props.record.id, partId: partId(), partType: "text" }) + }} + onMouseLeave={() => { + setHoveredDeletePartId(null) + props.onDeleteHoverChange?.({ kind: "none" }) + }} title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} > @@ -396,8 +403,8 @@ export default function MessageItem(props: MessageItemProps) { class="message-action-button" onClick={handleDeleteMessage} disabled={deletingMessage()} - onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} - onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} + onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })} + onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} > @@ -507,8 +514,16 @@ export default function MessageItem(props: MessageItemProps) { onClick={() => void handleDeletePart(attachment.id)} class="attachment-remove" disabled={isDeletingPart(attachment.id)} - onMouseEnter={() => (attachment.id ? setHoveredDeletePartId(attachment.id) : undefined)} - onMouseLeave={() => setHoveredDeletePartId(null)} + onMouseEnter={() => { + if (attachment.id) { + setHoveredDeletePartId(attachment.id) + props.onDeleteHoverChange?.({ kind: "part", messageId: props.record.id, partId: attachment.id, partType: "file" }) + } + }} + onMouseLeave={() => { + setHoveredDeletePartId(null) + props.onDeleteHoverChange?.({ kind: "none" }) + }} aria-label={t("messagePart.actions.deleteTitle")} title={t("messagePart.actions.deleteTitle")} > diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index f418700b..956de965 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -10,6 +10,7 @@ import { useI18n } from "../lib/i18n" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" +import type { DeleteHoverState } from "../types/delete-hover" const SCROLL_SCOPE = "session" const SCROLL_SENTINEL_MARGIN_PX = 48 @@ -145,6 +146,8 @@ export default function MessageSection(props: MessageSectionProps) { } } const [activeMessageId, setActiveMessageId] = createSignal(null) + + const [deleteHover, setDeleteHover] = createSignal({ kind: "none" }) const changeToken = createMemo(() => String(sessionRevision())) const isActive = createMemo(() => props.isActive !== false) @@ -899,6 +902,8 @@ export default function MessageSection(props: MessageSectionProps) { onRevert={props.onRevert} onFork={props.onFork} onContentRendered={handleContentRendered} + deleteHover={deleteHover} + onDeleteHoverChange={setDeleteHover} setBottomSentinel={setBottomSentinel} suspendMeasurements={() => !isActive()} /> @@ -957,6 +962,7 @@ export default function MessageSection(props: MessageSectionProps) { instanceId={props.instanceId} sessionId={props.sessionId} showToolSegments={showTimelineToolsPreference()} + deleteHover={deleteHover} /> diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index fc4ef7f3..86c077d9 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -7,6 +7,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach import { getToolIcon } from "./tool-call/utils" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { useI18n } from "../lib/i18n" +import type { DeleteHoverState } from "../types/delete-hover" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" @@ -19,6 +20,8 @@ export interface TimelineSegment { shortLabel?: string variant?: "auto" | "manual" toolPartIds?: string[] + partIds?: string[] + partId?: string } interface MessageTimelineProps { @@ -28,6 +31,7 @@ interface MessageTimelineProps { instanceId: string sessionId: string showToolSegments?: boolean + deleteHover?: () => DeleteHoverState } const MAX_TOOLTIP_LENGTH = 220 @@ -42,6 +46,7 @@ interface PendingSegment { toolTypeLabels: string[] toolIcons: string[] toolPartIds: string[] + partIds: string[] hasPrimaryText: boolean } @@ -191,6 +196,7 @@ export function buildTimelineSegments( tooltip, shortLabel, toolPartIds: isToolSegment ? pending.toolPartIds : undefined, + partIds: !isToolSegment ? pending.partIds : undefined, }) segmentIndex += 1 pending = null @@ -199,7 +205,17 @@ export function buildTimelineSegments( const ensureSegment = (type: TimelineSegmentType): PendingSegment => { if (!pending || pending.type !== type) { flushPending() - pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" } + pending = { + type, + texts: [], + reasoningTexts: [], + toolTitles: [], + toolTypeLabels: [], + toolIcons: [], + toolPartIds: [], + partIds: [], + hasPrimaryText: type !== "assistant", + } } return pending! } @@ -228,6 +244,9 @@ export function buildTimelineSegments( const target = ensureSegment(defaultContentType) if (target) { target.reasoningTexts.push(text) + if (typeof (part as any).id === "string" && (part as any).id.length > 0) { + target.partIds.push((part as any).id) + } } continue } @@ -235,6 +254,7 @@ export function buildTimelineSegments( if (part.type === "compaction") { flushPending() const isAuto = Boolean((part as any)?.auto) + const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : "" result.push({ id: `${record.id}:${segmentIndex}`, messageId: record.id, @@ -242,6 +262,7 @@ export function buildTimelineSegments( label: segmentLabel("compaction"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), variant: isAuto ? "auto" : "manual", + partId, }) segmentIndex += 1 continue @@ -257,6 +278,9 @@ export function buildTimelineSegments( if (target) { target.texts.push(text) target.hasPrimaryText = true + if (typeof (part as any).id === "string" && (part as any).id.length > 0) { + target.partIds.push((part as any).id) + } } } @@ -278,6 +302,7 @@ const MessageTimeline: Component = (props) => { let hoverTimer: number | null = null let closeTimer: number | null = null const showTools = () => props.showToolSegments ?? true + const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const } const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { if (element) { @@ -426,16 +451,34 @@ const MessageTimeline: Component = (props) => { } return ( -