fix(ui): keep delete selection consistent across stream and timeline
This commit is contained in:
@@ -318,9 +318,11 @@ interface ToolCallItemProps {
|
|||||||
partId: string
|
partId: string
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
showDeleteMessage?: boolean
|
showDeleteMessage?: boolean
|
||||||
|
deleteHover?: () => DeleteHoverState
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
selectedMessageIds?: () => Set<string>
|
selectedMessageIds?: () => Set<string>
|
||||||
|
selectedToolPartKeys?: () => Set<string>
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +333,26 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
|
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
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 record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const partEntry = createMemo(() => record()?.parts?.[props.partId])
|
const partEntry = createMemo(() => record()?.parts?.[props.partId])
|
||||||
@@ -408,7 +430,7 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
return (
|
return (
|
||||||
<Show when={toolPart()}>
|
<Show when={toolPart()}>
|
||||||
{(resolvedToolPart) => (
|
{(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-label">
|
||||||
<div class="tool-call-header-meta">
|
<div class="tool-call-header-meta">
|
||||||
<Show when={props.showDeleteMessage}>
|
<Show when={props.showDeleteMessage}>
|
||||||
@@ -543,6 +565,7 @@ interface MessageBlockProps {
|
|||||||
deleteHover?: () => DeleteHoverState
|
deleteHover?: () => DeleteHoverState
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
selectedMessageIds?: () => Set<string>
|
selectedMessageIds?: () => Set<string>
|
||||||
|
selectedToolPartKeys?: () => Set<string>
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
@@ -806,9 +829,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
showDeleteMessage={index() === 0}
|
showDeleteMessage={index() === 0}
|
||||||
|
deleteHover={props.deleteHover}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
selectedToolPartKeys={props.selectedToolPartKeys}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -294,8 +294,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClearTimelineSelection = () => {
|
const handleClearTimelineSelection = () => {
|
||||||
setSelectedTimelineIds(new Set<string>())
|
clearDeleteMode()
|
||||||
setLastSelectionAnchorId(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const applySelectionMode = (mode: "all" | "tools") => {
|
const applySelectionMode = (mode: "all" | "tools") => {
|
||||||
@@ -412,6 +411,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const allowed = deletableMessageIds()
|
const allowed = deletableMessageIds()
|
||||||
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
|
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 isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
|
||||||
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
|
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
|
||||||
|
|
||||||
@@ -492,12 +499,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
setDeleteHover({ kind: "none" })
|
setDeleteHover({ kind: "none" })
|
||||||
setSelectedTimelineIds(new Set<string>())
|
setSelectedTimelineIds(new Set<string>())
|
||||||
setLastSelectionAnchorId(null)
|
setLastSelectionAnchorId(null)
|
||||||
|
setIsDeleteMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const timelineIds = selectedTimelineIds()
|
const timelineIds = selectedTimelineIds()
|
||||||
if (timelineIds.size === 0) {
|
if (timelineIds.size === 0) {
|
||||||
setSelectedForDeletion(new Set<string>())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const segments = timelineSegments()
|
const segments = timelineSegments()
|
||||||
@@ -1073,6 +1080,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
deleteHover={deleteHover}
|
deleteHover={deleteHover}
|
||||||
onDeleteHoverChange={setDeleteHover}
|
onDeleteHoverChange={setDeleteHover}
|
||||||
selectedMessageIds={selectedForDeletion}
|
selectedMessageIds={selectedForDeletion}
|
||||||
|
selectedToolPartKeys={deleteToolPartKeys}
|
||||||
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
|||||||
@@ -715,6 +715,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDeleteSelected = () => {
|
||||||
|
const selected = props.selectedMessageIds?.()
|
||||||
|
if (!selected) return false
|
||||||
|
return selected.has(segment.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
const hasActivePermission = () => {
|
const hasActivePermission = () => {
|
||||||
if (segment.type !== "tool") return false
|
if (segment.type !== "tool") return false
|
||||||
const partIds = segment.toolPartIds ?? []
|
const partIds = segment.toolPartIds ?? []
|
||||||
@@ -727,7 +733,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
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
|
// Group visual indicators: tools belong to the same message as their
|
||||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||||
@@ -762,13 +770,13 @@ 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" : ""} ${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-current={isActive() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
|
|||||||
@@ -284,6 +284,22 @@
|
|||||||
color: var(--accent-primary) !important;
|
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:hover,
|
||||||
.message-timeline-segment-selected:focus-visible {
|
.message-timeline-segment-selected:focus-visible {
|
||||||
background-color: color-mix(in oklab, var(--accent-primary) 35%, var(--surface-base)) !important;
|
background-color: color-mix(in oklab, var(--accent-primary) 35%, var(--surface-base)) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user