fix(ui): keep delete selection consistent across stream and timeline
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user