fix(ui): keep delete selection consistent across stream and timeline

This commit is contained in:
Shantur Rathore
2026-03-04 00:41:23 +00:00
parent 1719802c0f
commit bec1af6523
4 changed files with 65 additions and 8 deletions

View File

@@ -318,9 +318,11 @@ interface ToolCallItemProps {
partId: string
onContentRendered?: () => void
showDeleteMessage?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
}
@@ -331,6 +333,26 @@ function ToolCallItem(props: ToolCallItemProps) {
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`))
const isDeleteOverlayActive = () => {
if (isSelectedForDeletion()) return true
if (isSelectedToolPartForDeletion()) return true
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const partEntry = createMemo(() => record()?.parts?.[props.partId])
@@ -408,7 +430,7 @@ function ToolCallItem(props: ToolCallItemProps) {
return (
<Show when={toolPart()}>
{(resolvedToolPart) => (
<div class="delete-hover-scope">
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
@@ -543,6 +565,7 @@ interface MessageBlockProps {
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
@@ -806,9 +829,11 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index() === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>

View File

@@ -294,8 +294,7 @@ export default function MessageSection(props: MessageSectionProps) {
}
const handleClearTimelineSelection = () => {
setSelectedTimelineIds(new Set<string>())
setLastSelectionAnchorId(null)
clearDeleteMode()
}
const applySelectionMode = (mode: "all" | "tools") => {
@@ -412,6 +411,14 @@ export default function MessageSection(props: MessageSectionProps) {
const allowed = deletableMessageIds()
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
})
const deleteToolPartKeys = createMemo(() => {
const set = new Set<string>()
for (const entry of deleteToolParts()) {
set.add(`${entry.messageId}:${entry.partId}`)
}
return set
})
const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
@@ -492,12 +499,12 @@ export default function MessageSection(props: MessageSectionProps) {
setDeleteHover({ kind: "none" })
setSelectedTimelineIds(new Set<string>())
setLastSelectionAnchorId(null)
setIsDeleteMenuOpen(false)
}
createEffect(() => {
const timelineIds = selectedTimelineIds()
if (timelineIds.size === 0) {
setSelectedForDeletion(new Set<string>())
return
}
const segments = timelineSegments()
@@ -1073,6 +1080,7 @@ export default function MessageSection(props: MessageSectionProps) {
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
selectedMessageIds={selectedForDeletion}
selectedToolPartKeys={deleteToolPartKeys}
onToggleSelectedMessage={setMessageSelectedForDeletion}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}

View File

@@ -715,6 +715,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return false
}
const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
@@ -727,7 +733,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () => segment.type === "tool" && !(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered())
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency).
@@ -762,13 +770,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
return (
<button
<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" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
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" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
data-delete-hover={isDeleteHovered() ? "true" : undefined}
data-delete-hover={isDeleteHovered() || isDeleteSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}

View File

@@ -284,6 +284,22 @@
color: var(--accent-primary) !important;
}
/* When a whole message is selected for deletion (via stream checkbox),
reflect that on all timeline segments for that message. */
.message-timeline-segment-delete-selected {
border-color: color-mix(in oklab, var(--status-error) 55%, transparent) !important;
background-color: color-mix(in oklab, var(--status-error) 18%, var(--surface-base)) !important;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--status-error) 35%, transparent) inset !important;
color: var(--status-error) !important;
}
.message-timeline-segment-delete-selected:hover,
.message-timeline-segment-delete-selected:focus-visible {
background-color: color-mix(in oklab, var(--status-error) 24%, var(--surface-base)) !important;
color: var(--status-error) !important;
transform: none;
}
.message-timeline-segment-selected:hover,
.message-timeline-segment-selected:focus-visible {
background-color: color-mix(in oklab, var(--accent-primary) 35%, var(--surface-base)) !important;