feat(ui): add delete message action to stream

This commit is contained in:
Shantur Rathore
2026-02-25 22:49:14 +00:00
parent 5834d2df1b
commit 362105fe78
9 changed files with 295 additions and 39 deletions

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, MessageSquareX, Trash2 } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -13,6 +13,7 @@ import { sessions, setActiveParentSession, setActiveSession } from "../stores/se
import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
const TOOL_ICON = "🔧"
@@ -196,6 +197,7 @@ interface MessageContentItemProps {
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
showDeleteMessage?: boolean
}
function isSupportedPartType(part: unknown): boolean {
@@ -282,6 +284,7 @@ function MessageContentItem(props: MessageContentItemProps) {
sessionId={props.sessionId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
@@ -298,11 +301,13 @@ interface ToolCallItemProps {
messageId: string
partId: string
onContentRendered?: () => void
showDeleteMessage?: boolean
}
function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false)
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -370,6 +375,27 @@ function ToolCallItem(props: ToolCallItemProps) {
}
}
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (deletingMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
return (
<Show when={toolPart()}>
{(resolvedToolPart) => (
@@ -381,7 +407,7 @@ function ToolCallItem(props: ToolCallItemProps) {
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
@@ -395,18 +421,31 @@ function ToolCallItem(props: ToolCallItemProps) {
</button>
</Show>
<button
class="tool-call-header-button"
type="button"
disabled={deleteDisabled()}
onClick={handleDeleteToolPart}
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deleteDisabled()}
onClick={handleDeleteToolPart}
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={props.showDeleteMessage}>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
</div>
<ToolCall
toolCall={resolvedToolPart()}
@@ -670,7 +709,7 @@ export default function MessageBlock(props: MessageBlockProps) {
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock().items}>
{(item) => (
{(item, index) => (
<Switch>
<Match when={item.type === "content"}>
<MessageContentItem
@@ -681,6 +720,7 @@ export default function MessageBlock(props: MessageBlockProps) {
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
@@ -697,6 +737,7 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index() === 0}
onContentRendered={props.onContentRendered}
/>
</div>
@@ -709,6 +750,10 @@ export default function MessageBlock(props: MessageBlockProps) {
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
/>
</Match>
<Match when={item.type === "step-finish"}>
@@ -718,6 +763,10 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
/>
</Match>
<Match when={item.type === "compaction"}>
@@ -729,6 +778,7 @@ export default function MessageBlock(props: MessageBlockProps) {
sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId}
partId={(item as CompactionDisplayItem).partId}
showDeleteMessage={index() === 0}
/>
</Match>
<Match when={item.type === "reasoning"}>
@@ -741,6 +791,7 @@ export default function MessageBlock(props: MessageBlockProps) {
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
/>
</Match>
</Switch>
@@ -759,6 +810,10 @@ interface StepCardProps {
showAgentMeta?: boolean
showUsage?: boolean
borderColor?: string
showDeleteMessage?: boolean
instanceId?: string
sessionId?: string
messageId?: string
}
interface CompactionCardProps {
@@ -769,11 +824,13 @@ interface CompactionCardProps {
sessionId: string
messageId: string
partId: string
showDeleteMessage?: boolean
}
function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false)
const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -801,6 +858,27 @@ function CompactionCard(props: CompactionCardProps) {
}
}
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
return (
<div
class={`${containerClass()} relative`}
@@ -808,15 +886,31 @@ function CompactionCard(props: CompactionCardProps) {
role="status"
aria-label={t("messageBlock.compaction.ariaLabel")}
>
<button
type="button"
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
disabled={!canDelete()}
onClick={handleDelete}
title={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</button>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<Show when={props.showDeleteMessage}>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="tool-call-header-button"
disabled={!canDelete()}
onClick={handleDelete}
title={t("messagePart.actions.deleteTitle")}
aria-label={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</button>
</div>
<div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
@@ -828,6 +922,7 @@ function CompactionCard(props: CompactionCardProps) {
function StepCard(props: StepCardProps) {
const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false)
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
@@ -872,6 +967,27 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const canDeleteMessage = () =>
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
@@ -902,7 +1018,20 @@ function StepCard(props: StepCardProps) {
return null
}
return (
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
<Show when={props.showDeleteMessage}>
<button
type="button"
class="message-action-button absolute right-2 top-1/2 -translate-y-1/2"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
{renderUsageChips(usage)}
</div>
)
@@ -942,12 +1071,14 @@ interface ReasoningCardProps {
partId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
showDeleteMessage?: boolean
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false)
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -1035,6 +1166,27 @@ function ReasoningCard(props: ReasoningCardProps) {
}
}
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
return (
<div class="message-reasoning-card">
<div class="message-reasoning-header">
@@ -1094,6 +1246,19 @@ function ReasoningCard(props: ReasoningCardProps) {
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<span class="message-reasoning-time">{timestamp()}</span>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import { Copy, MessageSquareX, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -7,7 +7,7 @@ import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps {
@@ -21,12 +21,14 @@ interface MessageItemProps {
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
onContentRendered?: () => void
showDeleteMessage?: boolean
}
export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n()
const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const [deletingMessage, setDeletingMessage] = createSignal(false)
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -234,6 +236,22 @@ export default function MessageItem(props: MessageItemProps) {
}
}
const handleDeleteMessage = async () => {
if (deletingMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId, props.sessionId, props.record.id)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
if (!isUser() && !hasContent() && !isGenerating()) {
return null
}
@@ -326,6 +344,18 @@ export default function MessageItem(props: MessageItemProps) {
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
<Show when={!isUser()}>
@@ -352,6 +382,18 @@ export default function MessageItem(props: MessageItemProps) {
</button>
)}
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<MessageSquareX class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>