feat(ui): mirror delete hover overlay in timeline

This commit is contained in:
Shantur Rathore
2026-02-25 23:32:32 +00:00
parent 1122070b9c
commit 0f9c99e3bd
8 changed files with 176 additions and 51 deletions

View File

@@ -2,6 +2,7 @@ import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item" import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
export function getMessageAnchorId(messageId: string) { export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}` return `message-anchor-${messageId}`
@@ -23,6 +24,8 @@ interface MessageBlockListProps {
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
deleteHover?: Accessor<DeleteHoverState>
onDeleteHoverChange?: (state: DeleteHoverState) => void
setBottomSentinel: (element: HTMLDivElement | null) => void setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean suspendMeasurements?: () => boolean
} }
@@ -51,6 +54,8 @@ export default function MessageBlockList(props: MessageBlockListProps) {
showThinking={props.showThinking} showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded} thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics} showUsageMetrics={props.showUsageMetrics}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}

View File

@@ -15,6 +15,7 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { deleteMessage } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -198,7 +199,7 @@ interface MessageContentItemProps {
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean showDeleteMessage?: boolean
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
function isSupportedPartType(part: unknown): boolean { function isSupportedPartType(part: unknown): boolean {
@@ -286,7 +287,7 @@ function MessageContentItem(props: MessageContentItemProps) {
isQueued={isQueued()} isQueued={isQueued()}
showAgentMeta={showAgentMeta()} showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage} showDeleteMessage={props.showDeleteMessage}
onDeleteMessageHoverChange={props.onDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
@@ -304,7 +305,7 @@ interface ToolCallItemProps {
partId: string partId: string
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean showDeleteMessage?: boolean
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
@@ -430,8 +431,14 @@ function ToolCallItem(props: ToolCallItemProps) {
type="button" type="button"
disabled={deleteDisabled()} disabled={deleteDisabled()}
onClick={handleDeleteToolPart} onClick={handleDeleteToolPart}
onMouseEnter={() => setHoverDeletePart(true)} onMouseEnter={() => {
onMouseLeave={() => setHoverDeletePart(false)} 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")} 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")} aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
> >
@@ -444,8 +451,8 @@ function ToolCallItem(props: ToolCallItemProps) {
type="button" type="button"
disabled={deletingMessage()} disabled={deletingMessage()}
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={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 showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
@@ -527,10 +536,10 @@ export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const [deleteMessageHovered, setDeleteMessageHovered] = createSignal(false)
const handleDeleteMessageHoverChange = (hovered: boolean) => { const isDeleteMessageHovered = () => {
setDeleteMessageHovered(hovered) const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
return hover.kind === "message" && hover.messageId === props.messageId
} }
const block = createMemo<MessageDisplayBlock | null>(() => { const block = createMemo<MessageDisplayBlock | null>(() => {
@@ -723,7 +732,7 @@ export default function MessageBlock(props: MessageBlockProps) {
<div <div
class="message-stream-block" class="message-stream-block"
data-message-id={resolvedBlock().record.id} data-message-id={resolvedBlock().record.id}
data-delete-message-hover={deleteMessageHovered() ? "true" : undefined} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
> >
<For each={resolvedBlock().items}> <For each={resolvedBlock().items}>
{(item, index) => ( {(item, index) => (
@@ -738,7 +747,7 @@ export default function MessageBlock(props: MessageBlockProps) {
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0} showDeleteMessage={index() === 0}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
@@ -756,7 +765,7 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0} showDeleteMessage={index() === 0}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </div>
@@ -773,7 +782,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
@@ -787,7 +796,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item.type === "compaction"}>
@@ -800,7 +809,7 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={(item as CompactionDisplayItem).messageId} messageId={(item as CompactionDisplayItem).messageId}
partId={(item as CompactionDisplayItem).partId} partId={(item as CompactionDisplayItem).partId}
showDeleteMessage={index() === 0} showDeleteMessage={index() === 0}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
@@ -814,7 +823,7 @@ export default function MessageBlock(props: MessageBlockProps) {
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0} showDeleteMessage={index() === 0}
onDeleteMessageHoverChange={handleDeleteMessageHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -837,7 +846,7 @@ interface StepCardProps {
instanceId?: string instanceId?: string
sessionId?: string sessionId?: string
messageId?: string messageId?: string
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
interface CompactionCardProps { interface CompactionCardProps {
@@ -849,7 +858,7 @@ interface CompactionCardProps {
messageId: string messageId: string
partId: string partId: string
showDeleteMessage?: boolean showDeleteMessage?: boolean
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
function CompactionCard(props: CompactionCardProps) { function CompactionCard(props: CompactionCardProps) {
@@ -920,8 +929,8 @@ function CompactionCard(props: CompactionCardProps) {
class="tool-call-header-button" class="tool-call-header-button"
disabled={!canDeleteMessage()} disabled={!canDeleteMessage()}
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={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" class="tool-call-header-button"
disabled={!canDelete()} disabled={!canDelete()}
onClick={handleDelete} onClick={handleDelete}
onMouseEnter={() => setHoverDeletePart(true)} onMouseEnter={() => {
onMouseLeave={() => setHoverDeletePart(false)} 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")} title={t("messagePart.actions.deleteTitle")}
aria-label={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" class="message-action-button absolute right-2 top-1/2 -translate-y-1/2"
disabled={!canDeleteMessage()} disabled={!canDeleteMessage()}
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={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 showAgentMeta?: boolean
defaultExpanded?: boolean defaultExpanded?: boolean
showDeleteMessage?: boolean showDeleteMessage?: boolean
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
@@ -1274,8 +1289,14 @@ function ReasoningCard(props: ReasoningCardProps) {
class="message-action-button" class="message-action-button"
onClick={handleDelete} onClick={handleDelete}
disabled={!canDelete()} disabled={!canDelete()}
onMouseEnter={() => setHoverDeletePart(true)} onMouseEnter={() => {
onMouseLeave={() => setHoverDeletePart(false)} 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")} aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")} title={t("messagePart.actions.deleteTitle")}
> >
@@ -1289,8 +1310,8 @@ function ReasoningCard(props: ReasoningCardProps) {
class="message-action-button" class="message-action-button"
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
disabled={!canDeleteMessage()} disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
> >

View File

@@ -9,6 +9,7 @@ import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -22,7 +23,7 @@ interface MessageItemProps {
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean showDeleteMessage?: boolean
onDeleteMessageHoverChange?: (hovered: boolean) => void onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
@@ -354,8 +355,8 @@ export default function MessageItem(props: MessageItemProps) {
class="message-action-button" class="message-action-button"
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
disabled={deletingMessage()} disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={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" class="message-action-button"
onClick={() => void handleDeletePart(partId())} onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())} disabled={isDeletingPart(partId())}
onMouseEnter={() => setHoveredDeletePartId(partId())} onMouseEnter={() => {
onMouseLeave={() => setHoveredDeletePartId(null)} 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")} title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={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" class="message-action-button"
onClick={handleDeleteMessage} onClick={handleDeleteMessage}
disabled={deletingMessage()} disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteMessageHoverChange?.(true)} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteMessageHoverChange?.(false)} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")} title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={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)} onClick={() => void handleDeletePart(attachment.id)}
class="attachment-remove" class="attachment-remove"
disabled={isDeletingPart(attachment.id)} disabled={isDeletingPart(attachment.id)}
onMouseEnter={() => (attachment.id ? setHoveredDeletePartId(attachment.id) : undefined)} onMouseEnter={() => {
onMouseLeave={() => setHoveredDeletePartId(null)} 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")} aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")} title={t("messagePart.actions.deleteTitle")}
> >

View File

@@ -10,6 +10,7 @@ import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -145,6 +146,8 @@ export default function MessageSection(props: MessageSectionProps) {
} }
} }
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null) const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
const changeToken = createMemo(() => String(sessionRevision())) const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false) const isActive = createMemo(() => props.isActive !== false)
@@ -899,6 +902,8 @@ export default function MessageSection(props: MessageSectionProps) {
onRevert={props.onRevert} onRevert={props.onRevert}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => !isActive()} suspendMeasurements={() => !isActive()}
/> />
@@ -957,6 +962,7 @@ export default function MessageSection(props: MessageSectionProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()} showToolSegments={showTimelineToolsPreference()}
deleteHover={deleteHover}
/> />
</div> </div>
</Show> </Show>

View File

@@ -7,6 +7,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
import { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -19,6 +20,8 @@ export interface TimelineSegment {
shortLabel?: string shortLabel?: string
variant?: "auto" | "manual" variant?: "auto" | "manual"
toolPartIds?: string[] toolPartIds?: string[]
partIds?: string[]
partId?: string
} }
interface MessageTimelineProps { interface MessageTimelineProps {
@@ -28,6 +31,7 @@ interface MessageTimelineProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
showToolSegments?: boolean showToolSegments?: boolean
deleteHover?: () => DeleteHoverState
} }
const MAX_TOOLTIP_LENGTH = 220 const MAX_TOOLTIP_LENGTH = 220
@@ -42,6 +46,7 @@ interface PendingSegment {
toolTypeLabels: string[] toolTypeLabels: string[]
toolIcons: string[] toolIcons: string[]
toolPartIds: string[] toolPartIds: string[]
partIds: string[]
hasPrimaryText: boolean hasPrimaryText: boolean
} }
@@ -191,6 +196,7 @@ export function buildTimelineSegments(
tooltip, tooltip,
shortLabel, shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined, toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
partIds: !isToolSegment ? pending.partIds : undefined,
}) })
segmentIndex += 1 segmentIndex += 1
pending = null pending = null
@@ -199,7 +205,17 @@ export function buildTimelineSegments(
const ensureSegment = (type: TimelineSegmentType): PendingSegment => { const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) { if (!pending || pending.type !== type) {
flushPending() 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! return pending!
} }
@@ -228,6 +244,9 @@ export function buildTimelineSegments(
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.reasoningTexts.push(text) 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 continue
} }
@@ -235,6 +254,7 @@ export function buildTimelineSegments(
if (part.type === "compaction") { if (part.type === "compaction") {
flushPending() flushPending()
const isAuto = Boolean((part as any)?.auto) const isAuto = Boolean((part as any)?.auto)
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -242,6 +262,7 @@ export function buildTimelineSegments(
label: segmentLabel("compaction"), label: segmentLabel("compaction"),
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
partId,
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -257,6 +278,9 @@ export function buildTimelineSegments(
if (target) { if (target) {
target.texts.push(text) target.texts.push(text)
target.hasPrimaryText = true 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<MessageTimelineProps> = (props) => {
let hoverTimer: number | null = null let hoverTimer: number | null = null
let closeTimer: number | null = null let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
@@ -426,16 +451,34 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
return ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
data-variant={segment.variant} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`} class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined} data-delete-hover={(() => {
aria-hidden={isHidden() ? "true" : undefined} const hover = deleteHover() as DeleteHoverState
onClick={() => props.onSegmentClick?.(segment)} if (hover.kind === "message") {
onMouseEnter={(event) => handleMouseEnter(segment, event)} return hover.messageId === segment.messageId ? "true" : undefined
}
if (hover.kind === "part") {
if (hover.messageId !== segment.messageId) return undefined
if (segment.type === "tool") {
return segment.toolPartIds?.includes(hover.partId) ? "true" : undefined
}
if (segment.type === "compaction") {
return segment.partId === hover.partId ? "true" : undefined
}
return segment.partIds?.includes(hover.partId) ? "true" : undefined
}
return undefined
})()}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> <span class="message-timeline-label message-timeline-label-full">{segment.label}</span>

View File

@@ -10,7 +10,7 @@
inset: -2px; inset: -2px;
background: var(--status-error-bg); background: var(--status-error-bg);
box-shadow: inset 0 0 0 1px var(--status-error-fg); box-shadow: inset 0 0 0 1px var(--status-error-fg);
border-radius: 10px; border-radius: 0;
pointer-events: none; pointer-events: none;
/* Overlay must sit above the message cards (they have opaque backgrounds). */ /* Overlay must sit above the message cards (they have opaque backgrounds). */
z-index: 10; z-index: 10;
@@ -30,7 +30,7 @@
inset: -2px; inset: -2px;
background: var(--status-error-bg); background: var(--status-error-bg);
box-shadow: inset 0 0 0 1px var(--status-error-fg); box-shadow: inset 0 0 0 1px var(--status-error-fg);
border-radius: 10px; border-radius: 0;
pointer-events: none; pointer-events: none;
/* Overlay must sit above the part card background. */ /* Overlay must sit above the part card background. */
z-index: 10; z-index: 10;

View File

@@ -87,6 +87,7 @@
border-radius: 0; border-radius: 0;
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -98,6 +99,36 @@
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
} }
.message-timeline-segment[data-delete-hover="true"]::before {
content: "";
position: absolute;
inset: 0;
background: var(--status-error-bg);
box-shadow: inset 0 0 0 1px var(--status-error-fg);
border-radius: 0;
pointer-events: none;
z-index: 2;
}
/* Ensure delete hover is visible even on the active segment styling. */
.message-timeline-segment[data-delete-hover="true"],
.message-timeline-segment[data-delete-hover="true"]:hover,
.message-timeline-segment[data-delete-hover="true"]:focus-visible,
.message-timeline-segment-active[data-delete-hover="true"],
.message-timeline-segment-active[data-delete-hover="true"]:hover,
.message-timeline-segment-active[data-delete-hover="true"]:focus-visible {
/* Let the ::before overlay provide the highlight (matches stream behavior). */
background-color: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
transform: none !important;
}
.message-timeline-label {
position: relative;
z-index: 3;
}
.message-timeline-segment.message-timeline-segment-hidden { .message-timeline-segment.message-timeline-segment-hidden {
display: none !important; display: none !important;
} }

View File

@@ -0,0 +1,4 @@
export type DeleteHoverState =
| { kind: "none" }
| { kind: "message"; messageId: string }
| { kind: "part"; messageId: string; partId: string; partType?: string }