Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/ui/src/components/message-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ interface MessageContentItemProps {
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
}

Expand Down Expand Up @@ -299,6 +300,7 @@ function MessageContentItem(props: MessageContentItemProps) {
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
Expand Down Expand Up @@ -582,11 +584,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)

const selected = props.selectedMessageIds?.() ?? new Set<string>()
if (selected.has(props.messageId)) {
return true
}

if (hover.kind === "message") {
return hover.messageId === props.messageId
}
Expand Down Expand Up @@ -812,6 +809,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
Expand Down Expand Up @@ -886,6 +884,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
Expand All @@ -902,6 +901,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
Expand Down Expand Up @@ -1280,6 +1280,7 @@ interface ReasoningCardProps {
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
}

Expand All @@ -1288,7 +1289,16 @@ function ReasoningCard(props: ReasoningCardProps) {
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isSelectedForDeletion = () => {
if (props.selectedMessageIds?.().has(props.messageId)) return true
const toolKeys = props.selectedToolPartKeys?.()
if (!toolKeys || toolKeys.size === 0) return false
const prefix = `${props.messageId}:`
for (const key of toolKeys) {
if (key.startsWith(prefix)) return true
}
return false
}

let headerEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
Expand Down Expand Up @@ -1427,7 +1437,10 @@ function ReasoningCard(props: ReasoningCardProps) {
}

return (
<div class="delete-hover-scope message-reasoning-card">
<div
class="delete-hover-scope message-reasoning-card"
data-delete-part-hover={isSelectedForDeletion() ? "true" : undefined}
>
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
<button
type="button"
Expand Down
17 changes: 15 additions & 2 deletions packages/ui/src/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface MessageItemProps {
parts: ClientPart[]
onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void
Expand Down Expand Up @@ -95,7 +96,16 @@ export default function MessageItem(props: MessageItemProps) {
})
})

const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
const isSelectedForDeletion = () => {
if (props.selectedMessageIds?.().has(props.record.id)) return true
const toolKeys = props.selectedToolPartKeys?.()
if (!toolKeys || toolKeys.size === 0) return false
const prefix = `${props.record.id}:`
for (const key of toolKeys) {
if (key.startsWith(prefix)) return true
}
return false
}

let topRowEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
Expand Down Expand Up @@ -390,7 +400,10 @@ export default function MessageItem(props: MessageItemProps) {
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<header
class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"} delete-hover-scope`}
data-delete-part-hover={isSelectedForDeletion() ? "true" : undefined}
>
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-header-left">
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
Expand Down
118 changes: 58 additions & 60 deletions packages/ui/src/components/message-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,42 +117,6 @@ export default function MessageSection(props: MessageSectionProps) {
let deleteMenuRef: HTMLDivElement | undefined
let deleteMenuButtonRef: HTMLButtonElement | undefined

// Deletion is only allowed for messages/tool parts that occur AFTER the most
// recent compaction. Compaction effectively resets the stored context; deleting
// earlier items would not reliably reflect what the model sees.
const messageIndexById = createMemo(() => {
const ids = messageIds()
const map = new Map<string, number>()
for (let i = 0; i < ids.length; i++) {
map.set(ids[i], i)
}
return map
})

const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small.
sessionRevision()
return untrack(() => store().getLastCompactionMessageIndex(props.sessionId))
})

const deletableStartIndex = createMemo(() => {
const idx = lastCompactionIndex()
return idx === -1 ? 0 : idx + 1
})

const deletableMessageIds = createMemo(() => {
const ids = messageIds()
const start = deletableStartIndex()
return new Set(ids.slice(start))
})

const isMessageDeletable = (messageId: string): boolean => {
const idx = messageIndexById().get(messageId)
if (idx === undefined) return false
return idx >= deletableStartIndex()
}

// Build the message group for a segment.
// Tool calls belong to the same assistant turn (between user messages).
// Only assistant badges trigger group selection; user/tool badges are standalone.
Expand Down Expand Up @@ -187,10 +151,6 @@ export default function MessageSection(props: MessageSectionProps) {
if (segmentIndex === -1) return
const segment = segments[segmentIndex]

if (!isMessageDeletable(segment.messageId)) {
return
}

setLastSelectionAnchorId(id)

if (selectionMode() === "tools" && segment.type !== "tool") {
Expand Down Expand Up @@ -236,10 +196,6 @@ export default function MessageSection(props: MessageSectionProps) {
const segmentIndex = segments.findIndex((s) => s.id === segment.id)
if (segmentIndex === -1) return

if (!isMessageDeletable(segment.messageId)) {
return
}

setLastSelectionAnchorId(segment.id)

if (selectionMode() === "tools" && segment.type !== "tool") {
Expand Down Expand Up @@ -287,8 +243,8 @@ export default function MessageSection(props: MessageSectionProps) {
const end = Math.max(anchorIndex, targetIndex)

const rangeSegments = selectionMode() === "tools"
? segments.slice(start, end + 1).filter((s) => s.type === "tool" && isMessageDeletable(s.messageId))
: segments.slice(start, end + 1).filter((s) => isMessageDeletable(s.messageId))
? segments.slice(start, end + 1).filter((s) => s.type === "tool")
: segments.slice(start, end + 1)
// Range selection replaces current selection so it can grow or shrink.
setSelectedTimelineIds(new Set(rangeSegments.map((segment) => segment.id)))
}
Expand All @@ -301,11 +257,7 @@ export default function MessageSection(props: MessageSectionProps) {
setSelectionMode(mode)
if (mode !== "tools") return
const segments = timelineSegments()
const toolIds = new Set(
segments
.filter((segment) => segment.type === "tool" && isMessageDeletable(segment.messageId))
.map((segment) => segment.id),
)
const toolIds = new Set(segments.filter((segment) => segment.type === "tool").map((segment) => segment.id))
setSelectedTimelineIds((prev) => {
if (prev.size === 0) return prev
const next = new Set([...prev].filter((id) => toolIds.has(id)))
Expand Down Expand Up @@ -408,8 +360,7 @@ export default function MessageSection(props: MessageSectionProps) {
const deleteMessageIds = createMemo(() => selectedForDeletion())
const deleteToolParts = createMemo(() => {
const messageIds = deleteMessageIds()
const allowed = deletableMessageIds()
return selectedToolParts().filter((entry) => allowed.has(entry.messageId) && !messageIds.has(entry.messageId))
return selectedToolParts().filter((entry) => !messageIds.has(entry.messageId))
})

const deleteToolPartKeys = createMemo(() => {
Expand Down Expand Up @@ -482,7 +433,6 @@ export default function MessageSection(props: MessageSectionProps) {

const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
if (!messageId) return
if (!isMessageDeletable(messageId)) return
setSelectedForDeletion((prev) => {
const next = new Set(prev)
if (selected) {
Expand Down Expand Up @@ -513,28 +463,38 @@ export default function MessageSection(props: MessageSectionProps) {
const affectedMessageIds = new Set<string>()
for (const segId of timelineIds) {
const segment = segmentById.get(segId)
if (segment && segment.type !== "tool" && isMessageDeletable(segment.messageId)) {
if (segment && segment.type !== "tool") {
affectedMessageIds.add(segment.messageId)
}
}
setSelectedForDeletion(affectedMessageIds)
})

const selectAllForDeletion = () => {
const allMessageIds = [...deletableMessageIds()]
setSelectedForDeletion(new Set<string>(allMessageIds))
setSelectedForDeletion(new Set<string>(messageIds()))
// Also select all timeline segments — tool visibility is handled by
// isSelectionActive() in isHidden(), no expand/collapse needed.
const segments = timelineSegments()
setSelectedTimelineIds(new Set(segments.filter((s) => isMessageDeletable(s.messageId)).map((s) => s.id)))
setSelectedTimelineIds(new Set(segments.map((s) => s.id)))
}

const deleteSelectedMessages = async () => {
const selected = deleteMessageIds()
const toolParts = deleteToolParts()
if (selected.size === 0 && toolParts.length === 0) return

const allowed = deletableMessageIds()
const allowed = new Set(messageIds())

const toolPartsByMessage = new Map<string, Set<string>>()
for (const entry of toolParts) {
if (!allowed.has(entry.messageId)) continue
let ids = toolPartsByMessage.get(entry.messageId)
if (!ids) {
ids = new Set<string>()
toolPartsByMessage.set(entry.messageId, ids)
}
ids.add(entry.partId)
}

const idsInSessionOrder = messageIds()
const toDelete: string[] = []
Expand All @@ -545,6 +505,37 @@ export default function MessageSection(props: MessageSectionProps) {
}
}

const stepFinishPartsToDelete: { messageId: string; partId: string }[] = []
const reasoningPartsToDelete: { messageId: string; partId: string }[] = []
if (toolPartsByMessage.size > 0) {
const s = store()
for (const [messageId, selectedToolPartIds] of toolPartsByMessage.entries()) {
if (selected.has(messageId)) continue
const record = s.getMessage(messageId)
if (!record) continue
const stepFinishPartIds: string[] = []
const reasoningPartIds: string[] = []
for (const partId of record.partIds ?? []) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data
if (!part) continue
if (part.type === "step-finish") {
stepFinishPartIds.push(partId)
}
if (part.type === "reasoning") {
reasoningPartIds.push(partId)
}
}
if (selectedToolPartIds.size === 0) continue
for (const partId of stepFinishPartIds) {
stepFinishPartsToDelete.push({ messageId, partId })
}
for (const partId of reasoningPartIds) {
reasoningPartsToDelete.push({ messageId, partId })
}
}
}

try {
for (const messageId of toDelete) {
await deleteMessage(props.instanceId, props.sessionId, messageId)
Expand All @@ -553,6 +544,14 @@ export default function MessageSection(props: MessageSectionProps) {
if (!allowed.has(messageId)) continue
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
}
for (const { messageId, partId } of stepFinishPartsToDelete) {
if (!allowed.has(messageId)) continue
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
}
for (const { messageId, partId } of reasoningPartsToDelete) {
if (!allowed.has(messageId)) continue
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
}
clearDeleteMode()
} catch (error) {
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
Expand Down Expand Up @@ -1239,7 +1238,6 @@ export default function MessageSection(props: MessageSectionProps) {
onClearSelection={handleClearTimelineSelection}
selectedIds={selectedTimelineIds}
expandedMessageIds={expandedMessageIds}
deletableMessageIds={deletableMessageIds}
activeSegmentId={activeSegmentId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/styles/messaging/delete-overlays.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@
.delete-hover-scope[data-delete-part-hover="true"]::before {
content: "";
position: absolute;
inset: -2px;
inset: -4px;
background: var(--status-error-bg);
border-radius: 0;
pointer-events: none;
/* Overlay must sit above the part card background. */
z-index: 10;
}



.message-reasoning-card[data-delete-part-hover="true"]::before {
inset: 0;
}
Loading