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
25 changes: 17 additions & 8 deletions packages/ui/src/components/message-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,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 @@ -396,6 +397,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 @@ -756,11 +758,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 @@ -1111,6 +1108,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId}
Expand Down Expand Up @@ -1302,15 +1300,15 @@ function StepCard(props: StepCardProps) {
}
const info = props.messageInfo
const part = props.part as any

// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null
}

return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
Expand Down Expand Up @@ -1507,6 +1505,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
onContentRendered?: () => void
forceExpanded?: boolean
Expand Down Expand Up @@ -1585,7 +1584,16 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
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
}

createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
Expand Down Expand Up @@ -1766,6 +1774,7 @@ function ReasoningCard(props: ReasoningCardProps) {
return (
<div
class="delete-hover-scope message-reasoning-card"
data-delete-part-hover={isSelectedForDeletion() ? "true" : undefined}
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<div class="message-reasoning-header">
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 @@ -33,6 +33,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 @@ -99,7 +100,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 @@ -468,7 +478,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
50 changes: 50 additions & 0 deletions packages/ui/src/components/message-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,17 @@ export default function MessageSection(props: MessageSectionProps) {

const allowed = deletableMessageIds()

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[] = []
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
Expand All @@ -623,6 +634,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 @@ -631,6 +673,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
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