feat(ui): mirror delete hover overlay in timeline
This commit is contained in:
@@ -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<DeleteHoverState>
|
||||
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}
|
||||
|
||||
@@ -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<MessageDisplayBlock | null>(() => {
|
||||
@@ -723,7 +732,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
<div
|
||||
class="message-stream-block"
|
||||
data-message-id={resolvedBlock().record.id}
|
||||
data-delete-message-hover={deleteMessageHovered() ? "true" : undefined}
|
||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||
>
|
||||
<For each={resolvedBlock().items}>
|
||||
{(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}
|
||||
/>
|
||||
</div>
|
||||
@@ -773,7 +782,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
onDeleteMessageHoverChange={handleDeleteMessageHoverChange}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
@@ -787,7 +796,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={props.messageId}
|
||||
onDeleteMessageHoverChange={handleDeleteMessageHoverChange}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
@@ -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}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
@@ -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}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
|
||||
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ 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}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -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<MessageTimelineProps> = (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<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""}`}
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""}`}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
data-delete-hover={(() => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
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}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
inset: -2px;
|
||||
background: var(--status-error-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--status-error-fg);
|
||||
border-radius: 10px;
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
/* Overlay must sit above the message cards (they have opaque backgrounds). */
|
||||
z-index: 10;
|
||||
@@ -30,7 +30,7 @@
|
||||
inset: -2px;
|
||||
background: var(--status-error-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--status-error-fg);
|
||||
border-radius: 10px;
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
/* Overlay must sit above the part card background. */
|
||||
z-index: 10;
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
4
packages/ui/src/types/delete-hover.ts
Normal file
4
packages/ui/src/types/delete-hover.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type DeleteHoverState =
|
||||
| { kind: "none" }
|
||||
| { kind: "message"; messageId: string }
|
||||
| { kind: "part"; messageId: string; partId: string; partType?: string }
|
||||
Reference in New Issue
Block a user