Compare commits
15 Commits
v0.11.5-de
...
v0.11.5-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d147ad49ff | ||
|
|
9b435e3621 | ||
|
|
ab9e188b02 | ||
|
|
2991de528a | ||
|
|
f1bd681618 | ||
|
|
b91dbb1a60 | ||
|
|
688b127c6d | ||
|
|
0f9c99e3bd | ||
|
|
1122070b9c | ||
|
|
57b81f00f8 | ||
|
|
362105fe78 | ||
|
|
5834d2df1b | ||
|
|
ef4c8ef425 | ||
|
|
5f755a7e1c | ||
|
|
8607fab5b5 |
47
.github/workflows/build-and-upload.yml
vendored
47
.github/workflows/build-and-upload.yml
vendored
@@ -61,7 +61,21 @@ jobs:
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||
exit 0
|
||||
fi
|
||||
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
@@ -72,6 +86,37 @@ jobs:
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
release_root="packages/electron-app/release"
|
||||
apps=()
|
||||
while IFS= read -r -d '' app; do
|
||||
apps+=("$app")
|
||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||
|
||||
if [ "${#apps[@]}" -eq 0 ]; then
|
||||
echo "No CodeNomad.app found under $release_root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||
# the shipped .app can fail Gatekeeper with:
|
||||
# code has no resources but signature indicates they must be present
|
||||
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||
for app in "${apps[@]}"; do
|
||||
echo "codesign (adhoc): $app"
|
||||
codesign --force --deep --sign - "$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
done
|
||||
else
|
||||
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||
fi
|
||||
|
||||
- name: Repackage Electron macOS zips (ditto)
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.10"
|
||||
"@opencode-ai/plugin": "1.2.14"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
} from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import IconButton from "@suid/material/IconButton"
|
||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { Session } from "../../../types/session"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Index, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
@@ -21,8 +22,13 @@ interface MessageBlockListProps {
|
||||
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
deleteHover?: Accessor<DeleteHoverState>
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
selectedMessageIds?: Accessor<Set<string>>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
suspendMeasurements?: () => boolean
|
||||
}
|
||||
@@ -51,7 +57,12 @@ export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
|
||||
@@ -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 { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -12,8 +12,17 @@ import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
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"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
@@ -23,10 +32,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
||||
|
||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||
return Boolean(state && state.status === "running")
|
||||
@@ -194,8 +203,13 @@ interface MessageContentItemProps {
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
function isSupportedPartType(part: unknown): boolean {
|
||||
@@ -282,7 +296,12 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
sessionId={props.sessionId}
|
||||
isQueued={isQueued()}
|
||||
showAgentMeta={showAgentMeta()}
|
||||
showDeleteMessage={props.showDeleteMessage}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
@@ -298,11 +317,19 @@ interface ToolCallItemProps {
|
||||
messageId: string
|
||||
partId: string
|
||||
onContentRendered?: () => void
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
function ToolCallItem(props: ToolCallItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
@@ -319,14 +346,6 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||
|
||||
const deleteDisabled = createMemo(() => {
|
||||
if (deleting()) return true
|
||||
// Avoid deleting while a tool is actively running to prevent confusing UI states.
|
||||
if (isToolStateRunning(toolState())) return true
|
||||
// Avoid deleting permission prompts from here; those are interactive.
|
||||
return Boolean(toolPart()?.pendingPermission)
|
||||
})
|
||||
|
||||
const taskSessionId = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return ""
|
||||
@@ -350,38 +369,72 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
navigateToTaskSession(location)
|
||||
}
|
||||
|
||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (deleteDisabled()) return
|
||||
if (!props.showDeleteMessage) return
|
||||
if (deletingMessage()) return
|
||||
|
||||
setDeleting(true)
|
||||
setDeletingMessage(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeletingMessage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.messageId)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={toolPart()}>
|
||||
{(resolvedToolPart) => (
|
||||
<>
|
||||
<div class="delete-hover-scope">
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>{t("messageBlock.tool.header")}</span>
|
||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-0">
|
||||
<Show when={taskSessionId()}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
@@ -395,16 +448,33 @@ 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>
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onClick={handleDeleteUpTo}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={deletingMessage()}
|
||||
onClick={handleDeleteMessage}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -418,7 +488,7 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
@@ -470,7 +540,12 @@ interface MessageBlockProps {
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
deleteHover?: () => DeleteHoverState
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
@@ -481,6 +556,30 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 block = createMemo<MessageDisplayBlock | null>(() => {
|
||||
const current = record()
|
||||
if (!current) return null
|
||||
@@ -668,9 +767,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
return (
|
||||
<Show when={block()}>
|
||||
{(resolvedBlock) => (
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
||||
<div
|
||||
class="message-stream-block"
|
||||
data-message-id={resolvedBlock().record.id}
|
||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||
>
|
||||
<For each={resolvedBlock().items}>
|
||||
{(item) => (
|
||||
{(item, index) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<MessageContentItem
|
||||
@@ -681,7 +784,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
startPartId={(item as ContentDisplayItem).startPartId}
|
||||
messageIndex={props.messageIndex}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
@@ -697,6 +805,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
partId={toolItem.partId}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
@@ -709,6 +822,14 @@ 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}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
@@ -718,6 +839,14 @@ 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}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
@@ -728,7 +857,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
partId={(item as CompactionDisplayItem).partId}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
@@ -738,9 +871,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
partId={(item as ReasoningDisplayItem).partId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -759,6 +896,14 @@ interface StepCardProps {
|
||||
showAgentMeta?: boolean
|
||||
showUsage?: boolean
|
||||
borderColor?: string
|
||||
showDeleteMessage?: boolean
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
messageId?: string
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
interface CompactionCardProps {
|
||||
@@ -768,12 +913,18 @@ interface CompactionCardProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
function CompactionCard(props: CompactionCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
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)
|
||||
@@ -781,44 +932,98 @@ function CompactionCard(props: CompactionCardProps) {
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
const canDelete = () => Boolean(props.partId) && !deleting()
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!canDeleteMessage()) return
|
||||
setDeletingMessage(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeletingMessage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.messageId)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`${containerClass()} relative`}
|
||||
class={`delete-hover-scope ${containerClass()} relative`}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
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={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onClick={handleDeleteUpTo}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-button"
|
||||
disabled={!canDeleteMessage()}
|
||||
onClick={handleDeleteMessage}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="message-compaction-row">
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
</div>
|
||||
@@ -828,6 +1033,9 @@ function CompactionCard(props: CompactionCardProps) {
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
@@ -872,6 +1080,42 @@ 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 handleDeleteUpTo = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!props.messageId) return
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.messageId)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
@@ -902,17 +1146,83 @@ 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 && props.messageId}>
|
||||
<input
|
||||
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.messageId!, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onClick={handleDeleteUpTo}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
disabled={!canDeleteMessage()}
|
||||
onClick={handleDeleteMessage}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{renderUsageChips(usage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`message-step-card message-step-start`}>
|
||||
<div class={`message-step-card message-step-start relative`}>
|
||||
<div class="message-step-heading">
|
||||
<div class="message-step-title">
|
||||
<div class="message-step-title-left">
|
||||
<Show when={props.showDeleteMessage && props.messageId}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.messageId!, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||
@@ -939,15 +1249,27 @@ interface ReasoningCardProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
|
||||
let headerEl: HTMLDivElement | undefined
|
||||
let actionsEl: HTMLDivElement | undefined
|
||||
let primaryEl: HTMLSpanElement | undefined
|
||||
let metaMeasureEl: HTMLSpanElement | undefined
|
||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -974,6 +1296,35 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
return modelID
|
||||
}
|
||||
|
||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||
|
||||
const updateMetaLayout = () => {
|
||||
if (!hasMeta()) return
|
||||
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
|
||||
|
||||
const headerWidth = headerEl.getBoundingClientRect().width
|
||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
||||
const primaryWidth = primaryEl.getBoundingClientRect().width
|
||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
||||
|
||||
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
|
||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!hasMeta() || typeof ResizeObserver === "undefined") {
|
||||
setShowMetaInline(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateMetaLayout()
|
||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
||||
if (headerEl) observer.observe(headerEl)
|
||||
if (actionsEl) observer.observe(actionsEl)
|
||||
if (primaryEl) observer.observe(primaryEl)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const reasoningText = () => {
|
||||
const part = props.part as any
|
||||
if (!part) return ""
|
||||
@@ -1014,30 +1365,45 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
const hasDeleteTarget = () => Boolean(props.partId)
|
||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDelete = async (event: MouseEvent) => {
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!canDeleteMessage()) return
|
||||
setDeletingMessage(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeletingMessage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.messageId)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<div class="message-reasoning-header">
|
||||
<div class="delete-hover-scope message-reasoning-card">
|
||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
@@ -1045,9 +1411,30 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-reasoning-label">
|
||||
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
</span>
|
||||
|
||||
<Show when={hasMeta() && showMetaInline()}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
@@ -1061,10 +1448,28 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={hasMeta()}>
|
||||
<span
|
||||
ref={(el) => (metaMeasureEl = el)}
|
||||
class="message-step-meta-inline message-step-meta-inline--measure"
|
||||
>
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions">
|
||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
@@ -1081,16 +1486,31 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
onClick={handleDelete}
|
||||
disabled={!canDelete()}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
onClick={handleDeleteUpTo}
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
onClick={handleDeleteMessage}
|
||||
disabled={!canDeleteMessage()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
@@ -1098,6 +1518,23 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={hasMeta() && !showMetaInline()}>
|
||||
<div class="message-reasoning-meta-row">
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { Copy, ListStart, Split, Trash, 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"
|
||||
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 } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -18,15 +27,61 @@ interface MessageItemProps {
|
||||
isQueued?: boolean
|
||||
parts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
}
|
||||
|
||||
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 [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
||||
|
||||
let topRowEl: HTMLDivElement | undefined
|
||||
let actionsEl: HTMLDivElement | undefined
|
||||
let speakerPrimaryEl: HTMLDivElement | undefined
|
||||
let metaMeasureEl: HTMLSpanElement | undefined
|
||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
||||
|
||||
const metaText = () => agentMeta()
|
||||
|
||||
const updateMetaLayout = () => {
|
||||
const text = metaText()
|
||||
if (!text) return
|
||||
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
|
||||
|
||||
const rowWidth = topRowEl.getBoundingClientRect().width
|
||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
||||
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
|
||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
||||
|
||||
// Allow for the flex gap between left and actions.
|
||||
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
|
||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const text = metaText()
|
||||
if (!text || typeof ResizeObserver === "undefined") {
|
||||
setShowMetaInline(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateMetaLayout()
|
||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
||||
if (topRowEl) observer.observe(topRowEl)
|
||||
if (actionsEl) observer.observe(actionsEl)
|
||||
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
@@ -190,47 +245,30 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const deletableTextPartId = () => {
|
||||
const part = props.parts.find((candidate) => {
|
||||
if (!candidate || candidate.type !== "text") return false
|
||||
const id = (candidate as any).id
|
||||
if (typeof id !== "string" || id.length === 0) return false
|
||||
return !Boolean((candidate as any).synthetic)
|
||||
})
|
||||
return (part as any)?.id as string | undefined
|
||||
}
|
||||
|
||||
const isDeletingPart = (partId?: string) => {
|
||||
if (!partId) return false
|
||||
return deletingParts().has(partId)
|
||||
}
|
||||
|
||||
const setPartDeleting = (partId: string, value: boolean) => {
|
||||
setDeletingParts((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) {
|
||||
next.add(partId)
|
||||
} else {
|
||||
next.delete(partId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePart = async (partId?: string) => {
|
||||
if (!partId) return
|
||||
if (isDeletingPart(partId)) return
|
||||
setPartDeleting(partId, true)
|
||||
const handleDeleteMessage = async () => {
|
||||
if (deletingMessage()) return
|
||||
setDeletingMessage(true)
|
||||
try {
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.record.id)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setPartDeleting(partId, false)
|
||||
setDeletingMessage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUpTo = async () => {
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.record.id)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,8 +296,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
|
||||
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
|
||||
if (!base) return ""
|
||||
|
||||
const variant = (info as SDKAssistantMessageV2).variant
|
||||
if (typeof variant === "string" && variant.trim().length > 0) {
|
||||
return `${base} (${variant.trim()})`
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
const agentMeta = () => {
|
||||
@@ -280,26 +326,58 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return (
|
||||
<div class={containerClass()}>
|
||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||
<div class="message-item-header-row message-item-header-row--top">
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
<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)}>
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
type="checkbox"
|
||||
checked={isSelectedForDeletion()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation()
|
||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||
props.onToggleSelectedMessage?.(props.record.id, next)
|
||||
}}
|
||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={metaText() && showMetaInline()}>
|
||||
<span class="message-agent-meta-inline">{metaText()}</span>
|
||||
</Show>
|
||||
|
||||
<Show when={metaText()}>
|
||||
<span
|
||||
ref={(el) => (metaMeasureEl = el)}
|
||||
class="message-agent-meta-inline message-agent-meta-inline--measure"
|
||||
>
|
||||
{metaText()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="message-item-actions">
|
||||
<div class="message-item-actions" ref={(el) => (actionsEl = el)}>
|
||||
<Show when={isUser()}>
|
||||
<div class="message-action-group">
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
@@ -310,14 +388,43 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revertTitle")}
|
||||
aria-label={t("messageItem.actions.revertTitle")}
|
||||
>
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeleteUpTo()}
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleDeleteMessage}
|
||||
disabled={deletingMessage()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
@@ -331,18 +438,30 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeleteUpTo()}
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleDeleteMessage}
|
||||
disabled={deletingMessage()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||
>
|
||||
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -350,12 +469,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={agentMeta()}>
|
||||
{(meta) => (
|
||||
<div class="message-item-header-row message-item-header-row--bottom">
|
||||
<span class="message-agent-meta">{meta()}</span>
|
||||
</div>
|
||||
)}
|
||||
<Show when={metaText() && !showMetaInline()}>
|
||||
<div class="message-item-header-row message-item-header-row--meta">
|
||||
<span class="message-agent-meta-block">{metaText()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</header>
|
||||
@@ -378,16 +495,20 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</Show>
|
||||
|
||||
<For each={messageParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
primaryUserTextPartId={primaryUserTextPartId()}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
{(part) => {
|
||||
return (
|
||||
<div class="message-part-shell">
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
primaryUserTextPartId={primaryUserTextPartId()}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
@@ -397,7 +518,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<div
|
||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
||||
title={name}
|
||||
>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
@@ -425,19 +549,6 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeletePart(attachment.id)}
|
||||
class="attachment-remove"
|
||||
disabled={isDeletingPart(attachment.id)}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
interface MessagePreviewProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
store: () => InstanceMessageStore
|
||||
deleteHover?: () => DeleteHoverState
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
@@ -24,6 +30,11 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
showUsageMetrics={() => false}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { CheckSquare, Trash, X } from "lucide-solid"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
@@ -9,7 +10,10 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
@@ -23,6 +27,7 @@ export interface MessageSectionProps {
|
||||
sessionId: string
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
showSidebarToggle?: boolean
|
||||
@@ -145,6 +150,63 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
}
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
|
||||
|
||||
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
|
||||
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
|
||||
const selectedDeleteCount = createMemo(() => selectedForDeletion().size)
|
||||
|
||||
const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
|
||||
|
||||
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
|
||||
if (!messageId) return
|
||||
setSelectedForDeletion((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (selected) {
|
||||
next.add(messageId)
|
||||
} else {
|
||||
next.delete(messageId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearDeleteMode = () => {
|
||||
setSelectedForDeletion(new Set<string>())
|
||||
setDeleteHover({ kind: "none" })
|
||||
}
|
||||
|
||||
const selectAllForDeletion = () => {
|
||||
setSelectedForDeletion(new Set<string>(messageIds()))
|
||||
}
|
||||
|
||||
const deleteSelectedMessages = async () => {
|
||||
const selected = selectedForDeletion()
|
||||
if (selected.size === 0) return
|
||||
|
||||
const idsInSessionOrder = messageIds()
|
||||
const toDelete: string[] = []
|
||||
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
|
||||
const id = idsInSessionOrder[idx]
|
||||
if (selected.has(id)) {
|
||||
toDelete.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const messageId of toDelete) {
|
||||
await deleteMessage(props.instanceId, props.sessionId, messageId)
|
||||
}
|
||||
clearDeleteMode()
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
||||
title: t("messageSection.bulkDelete.failedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
const isActive = createMemo(() => props.isActive !== false)
|
||||
@@ -167,6 +229,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
@@ -851,7 +914,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div
|
||||
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
@@ -897,8 +963,13 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
deleteHover={deleteHover}
|
||||
onDeleteHoverChange={setDeleteHover}
|
||||
selectedMessageIds={selectedForDeletion}
|
||||
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
/>
|
||||
@@ -957,9 +1028,56 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showToolSegments={showTimelineToolsPreference()}
|
||||
deleteHover={deleteHover}
|
||||
onDeleteHoverChange={setDeleteHover}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={selectedForDeletion}
|
||||
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isDeleteMode()}>
|
||||
<div
|
||||
class="message-delete-mode-toolbar"
|
||||
role="toolbar"
|
||||
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
|
||||
>
|
||||
<span class="message-delete-mode-count" aria-hidden="true">
|
||||
{selectedDeleteCount()}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-delete-mode-button"
|
||||
onClick={() => void deleteSelectedMessages()}
|
||||
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
|
||||
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
|
||||
>
|
||||
<Trash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-delete-mode-button"
|
||||
onClick={selectAllForDeletion}
|
||||
title={t("messageSection.bulkDelete.selectAllTitle")}
|
||||
aria-label={t("messageSection.bulkDelete.selectAllTitle")}
|
||||
>
|
||||
<CheckSquare class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-delete-mode-button"
|
||||
onClick={clearDeleteMode}
|
||||
title={t("messageSection.bulkDelete.cancelTitle")}
|
||||
aria-label={t("messageSection.bulkDelete.cancelTitle")}
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||
|
||||
@@ -19,6 +20,8 @@ export interface TimelineSegment {
|
||||
shortLabel?: string
|
||||
variant?: "auto" | "manual"
|
||||
toolPartIds?: string[]
|
||||
partIds?: string[]
|
||||
partId?: string
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
@@ -28,6 +31,11 @@ interface MessageTimelineProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showToolSegments?: boolean
|
||||
deleteHover?: () => DeleteHoverState
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
}
|
||||
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
@@ -38,10 +46,7 @@ interface PendingSegment {
|
||||
type: TimelineSegmentType
|
||||
texts: string[]
|
||||
reasoningTexts: string[]
|
||||
toolTitles: string[]
|
||||
toolTypeLabels: string[]
|
||||
toolIcons: string[]
|
||||
toolPartIds: string[]
|
||||
partIds: string[]
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
@@ -171,17 +176,12 @@ export function buildTimelineSegments(
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
const isToolSegment = pending.type === "tool"
|
||||
const label = isToolSegment
|
||||
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||
: segmentLabel(pending.type)
|
||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||
const tooltip = isToolSegment
|
||||
? formatToolTooltip(pending.toolTitles, t)
|
||||
: formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||
)
|
||||
const label = segmentLabel(pending.type)
|
||||
const shortLabel = undefined
|
||||
const tooltip = formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||
)
|
||||
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
@@ -190,7 +190,7 @@ export function buildTimelineSegments(
|
||||
label,
|
||||
tooltip,
|
||||
shortLabel,
|
||||
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||
partIds: pending.partIds,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
@@ -199,7 +199,13 @@ export function buildTimelineSegments(
|
||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||
pending = {
|
||||
type,
|
||||
texts: [],
|
||||
reasoningTexts: [],
|
||||
partIds: [],
|
||||
hasPrimaryText: type !== "assistant",
|
||||
}
|
||||
}
|
||||
return pending!
|
||||
}
|
||||
@@ -211,14 +217,20 @@ export function buildTimelineSegments(
|
||||
if (!part || typeof part !== "object") continue
|
||||
|
||||
if (part.type === "tool") {
|
||||
const target = ensureSegment("tool")
|
||||
flushPending()
|
||||
const toolPart = part as ToolCallPart
|
||||
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||
target.toolPartIds.push(toolPart.id)
|
||||
}
|
||||
const partId = typeof toolPart.id === "string" ? toolPart.id : ""
|
||||
const title = getToolTitle(toolPart, t)
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: "tool",
|
||||
label: getToolTypeLabel(toolPart, t) || segmentLabel("tool"),
|
||||
tooltip: formatToolTooltip([title], t),
|
||||
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
|
||||
toolPartIds: partId ? [partId] : undefined,
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -228,6 +240,9 @@ export function buildTimelineSegments(
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.reasoningTexts.push(text)
|
||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||
target.partIds.push((part as any).id)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -235,6 +250,7 @@ export function buildTimelineSegments(
|
||||
if (part.type === "compaction") {
|
||||
flushPending()
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
@@ -242,6 +258,7 @@ export function buildTimelineSegments(
|
||||
label: segmentLabel("compaction"),
|
||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
partId,
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
@@ -257,6 +274,9 @@ export function buildTimelineSegments(
|
||||
if (target) {
|
||||
target.texts.push(text)
|
||||
target.hasPrimaryText = true
|
||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||
target.partIds.push((part as any).id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +298,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
let hoverTimer: number | null = null
|
||||
let closeTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
@@ -396,6 +417,28 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
const selected = props.selectedMessageIds?.() ?? new Set<string>()
|
||||
if (selected.has(segment.messageId)) {
|
||||
return true
|
||||
}
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
const ids = store().getSessionMessageIds(props.sessionId)
|
||||
const targetIndex = ids.indexOf(hover.messageId)
|
||||
if (targetIndex === -1) return false
|
||||
const segmentIndex = ids.indexOf(segment.messageId)
|
||||
if (segmentIndex === -1) return false
|
||||
return segmentIndex >= targetIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
@@ -407,7 +450,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission() || isDeleteHovered())
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
@@ -427,15 +470,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
|
||||
return (
|
||||
<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" : ""}`}
|
||||
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" : ""}`}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
data-delete-hover={isDeleteHovered() ? "true" : undefined}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
@@ -460,6 +505,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { deleteMessage } from "../../stores/session-actions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
@@ -225,6 +226,35 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteMessagesUpTo(messageId: string) {
|
||||
const ids = messageStore().getSessionMessageIds(props.sessionId)
|
||||
const index = ids.indexOf(messageId)
|
||||
if (index === -1) return
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
const toDelete = ids.slice(index)
|
||||
|
||||
try {
|
||||
for (let idx = toDelete.length - 1; idx >= 0; idx -= 1) {
|
||||
await deleteMessage(props.instanceId, props.sessionId, toDelete[idx])
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete messages up to", error)
|
||||
showAlertDialog(t("sessionView.alerts.deleteUpToFailed.message"), {
|
||||
title: t("sessionView.alerts.deleteUpToFailed.title"),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
if (restoredText) {
|
||||
if (promptInputApi) {
|
||||
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||
} else {
|
||||
pendingPromptText = restoredText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFork(messageId?: string) {
|
||||
if (!messageId) {
|
||||
log.warn("Fork requires a user message id")
|
||||
@@ -283,10 +313,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
<MessageSection
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
if (props.isActive) {
|
||||
|
||||
@@ -44,7 +44,7 @@ import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||
import { tGlobal } from "../../lib/i18n"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { TextPart } from "../../types/message"
|
||||
import { Markdown } from "../markdown"
|
||||
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
import { useI18n, tGlobal } from "../../../lib/i18n"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||
import { enMessages } from "../../lib/i18n/messages/en"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
|
||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||
import { getLanguageFromPath } from "../../lib/markdown"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { tGlobal } from "../../lib/i18n"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
export type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
||||
export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
||||
export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
||||
|
||||
export const diffCapableTools = new Set(["edit", "patch"])
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Go to Session",
|
||||
"messageBlock.tool.goToSession.title": "Go to session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
||||
"messageBlock.tool.deletePart.label": "Delete",
|
||||
"messageBlock.tool.deletePart.label": "Delete Part",
|
||||
"messageBlock.tool.deletePart.deleting": "Deleting...",
|
||||
"messageBlock.tool.deletePart.title": "Delete this tool call output",
|
||||
"messageBlock.tool.deletePart.failed.title": "Delete failed",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "You",
|
||||
"messageItem.speaker.assistant": "Assistant",
|
||||
"messageItem.actions.revert": "Revert",
|
||||
"messageItem.actions.revertTitle": "Revert to this message",
|
||||
"messageItem.actions.revertTitle": "Undo changes up to here (deletes messages)",
|
||||
"messageItem.actions.fork": "Fork",
|
||||
"messageItem.actions.forkTitle": "Fork from this message",
|
||||
"messageItem.actions.copy": "Copy",
|
||||
"messageItem.actions.copyTitle": "Copy message",
|
||||
"messageItem.actions.copied": "Copied!",
|
||||
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
|
||||
"messageItem.actions.deletingMessage": "Deleting...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "Delete failed",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "Failed to delete message",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Selected messages ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected messages",
|
||||
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
|
||||
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
|
||||
"messageSection.bulkDelete.failedTitle": "Delete failed",
|
||||
"messageSection.bulkDelete.failedMessage": "Failed to delete selected messages",
|
||||
"messageItem.status.queued": "QUEUED",
|
||||
"messageItem.status.generating": "Generating...",
|
||||
"messageItem.status.sending": "Sending...",
|
||||
"messageItem.status.failedToSend": "Message failed to send",
|
||||
"messagePart.actions.delete": "Delete",
|
||||
"messagePart.actions.delete": "Delete Part",
|
||||
"messagePart.actions.deleting": "Deleting...",
|
||||
"messagePart.actions.deleteTitle": "Delete this item",
|
||||
"messagePart.actions.deleteFailedTitle": "Delete failed",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "Stop failed",
|
||||
"sessionView.alerts.revertFailed.message": "Failed to revert to message",
|
||||
"sessionView.alerts.revertFailed.title": "Revert failed",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "Failed to delete messages",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "Delete failed",
|
||||
"sessionView.alerts.forkFailed.message": "Failed to fork session",
|
||||
"sessionView.alerts.forkFailed.title": "Fork failed",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Ir a sesión",
|
||||
"messageBlock.tool.goToSession.title": "Ir a la sesión",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
|
||||
"messageBlock.tool.deletePart.label": "Eliminar",
|
||||
"messageBlock.tool.deletePart.label": "Eliminar parte",
|
||||
"messageBlock.tool.deletePart.deleting": "Eliminando...",
|
||||
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
|
||||
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "Tú",
|
||||
"messageItem.speaker.assistant": "Asistente",
|
||||
"messageItem.actions.revert": "Revertir",
|
||||
"messageItem.actions.revertTitle": "Revertir a este mensaje",
|
||||
"messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)",
|
||||
"messageItem.actions.fork": "Fork",
|
||||
"messageItem.actions.forkTitle": "Fork desde este mensaje",
|
||||
"messageItem.actions.copy": "Copiar",
|
||||
"messageItem.actions.copyTitle": "Copiar mensaje",
|
||||
"messageItem.actions.copied": "¡Copiado!",
|
||||
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
|
||||
"messageItem.actions.deletingMessage": "Eliminando...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "Error al eliminar",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Mensajes seleccionados ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar mensajes seleccionados",
|
||||
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
|
||||
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
||||
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
||||
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
|
||||
"messageItem.status.queued": "EN COLA",
|
||||
"messageItem.status.generating": "Generando...",
|
||||
"messageItem.status.sending": "Enviando...",
|
||||
"messageItem.status.failedToSend": "No se pudo enviar el mensaje",
|
||||
"messagePart.actions.delete": "Eliminar",
|
||||
"messagePart.actions.delete": "Eliminar parte",
|
||||
"messagePart.actions.deleting": "Eliminando...",
|
||||
"messagePart.actions.deleteTitle": "Eliminar este elemento",
|
||||
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "No se pudo detener",
|
||||
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
|
||||
"sessionView.alerts.revertFailed.title": "No se pudo revertir",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "No se pudieron eliminar los mensajes",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "Error al eliminar",
|
||||
"sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión",
|
||||
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.title": "Aller à la session",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
|
||||
"messageBlock.tool.deletePart.label": "Supprimer",
|
||||
"messageBlock.tool.deletePart.label": "Supprimer la partie",
|
||||
"messageBlock.tool.deletePart.deleting": "Suppression...",
|
||||
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
|
||||
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "Vous",
|
||||
"messageItem.speaker.assistant": "Assistant",
|
||||
"messageItem.actions.revert": "Revenir",
|
||||
"messageItem.actions.revertTitle": "Revenir à ce message",
|
||||
"messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)",
|
||||
"messageItem.actions.fork": "Fork",
|
||||
"messageItem.actions.forkTitle": "Fork depuis ce message",
|
||||
"messageItem.actions.copy": "Copier",
|
||||
"messageItem.actions.copyTitle": "Copier le message",
|
||||
"messageItem.actions.copied": "Copié !",
|
||||
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
|
||||
"messageItem.actions.deletingMessage": "Suppression...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "Échec de suppression",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Messages sélectionnés ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les messages sélectionnés",
|
||||
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
|
||||
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
||||
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
||||
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
|
||||
"messageItem.status.queued": "EN FILE",
|
||||
"messageItem.status.generating": "Génération...",
|
||||
"messageItem.status.sending": "Envoi...",
|
||||
"messageItem.status.failedToSend": "Échec de l'envoi du message",
|
||||
"messagePart.actions.delete": "Supprimer",
|
||||
"messagePart.actions.delete": "Supprimer la partie",
|
||||
"messagePart.actions.deleting": "Suppression...",
|
||||
"messagePart.actions.deleteTitle": "Supprimer cet élément",
|
||||
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
|
||||
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
|
||||
"sessionView.alerts.revertFailed.title": "Échec du retour",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "Impossible de supprimer les messages",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "Échec de suppression",
|
||||
"sessionView.alerts.forkFailed.message": "Impossible de forker la session",
|
||||
"sessionView.alerts.forkFailed.title": "Échec du fork",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.title": "セッションへ移動",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
|
||||
"messageBlock.tool.deletePart.label": "削除",
|
||||
"messageBlock.tool.deletePart.label": "パートを削除",
|
||||
"messageBlock.tool.deletePart.deleting": "削除中...",
|
||||
"messageBlock.tool.deletePart.title": "このツール出力を削除",
|
||||
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "あなた",
|
||||
"messageItem.speaker.assistant": "アシスタント",
|
||||
"messageItem.actions.revert": "戻す",
|
||||
"messageItem.actions.revertTitle": "このメッセージまで戻す",
|
||||
"messageItem.actions.revertTitle": "ここまでの変更を元に戻す(メッセージを削除)",
|
||||
"messageItem.actions.fork": "フォーク",
|
||||
"messageItem.actions.forkTitle": "このメッセージからフォーク",
|
||||
"messageItem.actions.copy": "コピー",
|
||||
"messageItem.actions.copyTitle": "メッセージをコピー",
|
||||
"messageItem.actions.copied": "コピーしました!",
|
||||
"messageItem.actions.deleteMessage": "メッセージを削除(変更は元に戻さない)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "ここまでのメッセージを削除(変更は元に戻さない)",
|
||||
"messageItem.actions.deletingMessage": "削除中...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "削除に失敗しました",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "メッセージの削除に失敗しました",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "選択したメッセージ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "選択したメッセージを削除",
|
||||
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
|
||||
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
||||
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
||||
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
|
||||
"messageItem.status.queued": "待機中",
|
||||
"messageItem.status.generating": "生成中...",
|
||||
"messageItem.status.sending": "送信中...",
|
||||
"messageItem.status.failedToSend": "メッセージの送信に失敗しました",
|
||||
"messagePart.actions.delete": "削除",
|
||||
"messagePart.actions.delete": "パートを削除",
|
||||
"messagePart.actions.deleting": "削除中...",
|
||||
"messagePart.actions.deleteTitle": "この項目を削除",
|
||||
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "停止に失敗",
|
||||
"sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした",
|
||||
"sessionView.alerts.revertFailed.title": "復元に失敗",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "メッセージの削除に失敗しました",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "削除に失敗しました",
|
||||
"sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました",
|
||||
"sessionView.alerts.forkFailed.title": "フォークに失敗",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.title": "Перейти к сессии",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
|
||||
"messageBlock.tool.deletePart.label": "Удалить",
|
||||
"messageBlock.tool.deletePart.label": "Удалить часть",
|
||||
"messageBlock.tool.deletePart.deleting": "Удаление...",
|
||||
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
|
||||
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "Вы",
|
||||
"messageItem.speaker.assistant": "Ассистент",
|
||||
"messageItem.actions.revert": "Откатить",
|
||||
"messageItem.actions.revertTitle": "Откатиться к этому сообщению",
|
||||
"messageItem.actions.revertTitle": "Отменить изменения до этого места (удалит сообщения)",
|
||||
"messageItem.actions.fork": "Форк",
|
||||
"messageItem.actions.forkTitle": "Форкнуть от этого сообщения",
|
||||
"messageItem.actions.copy": "Копировать",
|
||||
"messageItem.actions.copyTitle": "Копировать сообщение",
|
||||
"messageItem.actions.copied": "Скопировано!",
|
||||
"messageItem.actions.deleteMessage": "Удалить сообщение (без отката изменений)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Удалить сообщения до этого места (без отката изменений)",
|
||||
"messageItem.actions.deletingMessage": "Удаление...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "Ошибка удаления",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "Не удалось удалить сообщение",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные сообщения ({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные сообщения",
|
||||
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
|
||||
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
||||
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
||||
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
|
||||
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
||||
"messageItem.status.generating": "Генерация…",
|
||||
"messageItem.status.sending": "Отправка…",
|
||||
"messageItem.status.failedToSend": "Не удалось отправить сообщение",
|
||||
"messagePart.actions.delete": "Удалить",
|
||||
"messagePart.actions.delete": "Удалить часть",
|
||||
"messagePart.actions.deleting": "Удаление...",
|
||||
"messagePart.actions.deleteTitle": "Удалить этот элемент",
|
||||
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "Не удалось остановить",
|
||||
"sessionView.alerts.revertFailed.message": "Не удалось откатиться к сообщению",
|
||||
"sessionView.alerts.revertFailed.title": "Не удалось откатиться",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "Не удалось удалить сообщения",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "Ошибка удаления",
|
||||
"sessionView.alerts.forkFailed.message": "Не удалось форкнуть сессию",
|
||||
"sessionView.alerts.forkFailed.title": "Не удалось форкнуть",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "Развернуть вставленный текст",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
||||
"messageBlock.tool.goToSession.label": "前往会话",
|
||||
"messageBlock.tool.goToSession.title": "前往会话",
|
||||
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
|
||||
"messageBlock.tool.deletePart.label": "删除",
|
||||
"messageBlock.tool.deletePart.label": "删除部分",
|
||||
"messageBlock.tool.deletePart.deleting": "正在删除...",
|
||||
"messageBlock.tool.deletePart.title": "删除此工具输出",
|
||||
"messageBlock.tool.deletePart.failed.title": "删除失败",
|
||||
@@ -71,17 +71,31 @@ export const messagingMessages = {
|
||||
"messageItem.speaker.you": "你",
|
||||
"messageItem.speaker.assistant": "助手",
|
||||
"messageItem.actions.revert": "回退",
|
||||
"messageItem.actions.revertTitle": "回退到这条消息",
|
||||
"messageItem.actions.revertTitle": "撤销到此处的更改(会删除消息)",
|
||||
"messageItem.actions.fork": "分叉",
|
||||
"messageItem.actions.forkTitle": "从这条消息分叉",
|
||||
"messageItem.actions.copy": "复制",
|
||||
"messageItem.actions.copyTitle": "复制消息",
|
||||
"messageItem.actions.copied": "已复制!",
|
||||
"messageItem.actions.deleteMessage": "删除消息(不会撤销更改)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "删除到此处的消息(不会撤销更改)",
|
||||
"messageItem.actions.deletingMessage": "正在删除...",
|
||||
"messageItem.actions.deleteMessageFailedTitle": "删除失败",
|
||||
"messageItem.actions.deleteMessageFailedMessage": "无法删除消息",
|
||||
|
||||
"messageItem.selection.checkboxAriaLabel": "选择要删除的消息",
|
||||
|
||||
"messageSection.bulkDelete.toolbarAriaLabel": "已选择的消息({count})",
|
||||
"messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的消息",
|
||||
"messageSection.bulkDelete.selectAllTitle": "全选消息",
|
||||
"messageSection.bulkDelete.cancelTitle": "取消选择",
|
||||
"messageSection.bulkDelete.failedTitle": "删除失败",
|
||||
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
|
||||
"messageItem.status.queued": "排队中",
|
||||
"messageItem.status.generating": "正在生成...",
|
||||
"messageItem.status.sending": "正在发送...",
|
||||
"messageItem.status.failedToSend": "消息发送失败",
|
||||
"messagePart.actions.delete": "删除",
|
||||
"messagePart.actions.delete": "删除部分",
|
||||
"messagePart.actions.deleting": "正在删除...",
|
||||
"messagePart.actions.deleteTitle": "删除此项",
|
||||
"messagePart.actions.deleteFailedTitle": "删除失败",
|
||||
|
||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
||||
"sessionView.alerts.abortFailed.title": "停止失败",
|
||||
"sessionView.alerts.revertFailed.message": "回退到消息失败",
|
||||
"sessionView.alerts.revertFailed.title": "回退失败",
|
||||
"sessionView.alerts.deleteUpToFailed.message": "无法删除消息",
|
||||
"sessionView.alerts.deleteUpToFailed.title": "删除失败",
|
||||
"sessionView.alerts.forkFailed.message": "分叉会话失败",
|
||||
"sessionView.alerts.forkFailed.title": "分叉失败",
|
||||
"sessionView.attachments.expandPastedTextAriaLabel": "展开粘贴的文本",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { providers, sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { removeMessagePartV2 } from "./message-v2/bridge"
|
||||
import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
@@ -439,8 +439,33 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function deleteMessage(instanceId: string, sessionId: string, messageId: string): Promise<void> {
|
||||
if (!instanceId || !sessionId || !messageId) return
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
// The SDK generator does not currently expose a typed method for deleting a message,
|
||||
// but the API is available at DELETE /session/:sessionID/message/:messageID.
|
||||
await requestData(
|
||||
(client as any).client.delete({
|
||||
url: `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`,
|
||||
}),
|
||||
"session.message.delete",
|
||||
)
|
||||
|
||||
// Optimistic removal; SSE will also broadcast a message-removed event.
|
||||
removeMessageV2(instanceId, messageId)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
deleteMessage,
|
||||
deleteMessagePart,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
@import "./messaging/prompt-input.css";
|
||||
@import "./messaging/message-section.css";
|
||||
@import "./messaging/message-block-list.css";
|
||||
@import "./messaging/message-selection.css";
|
||||
@import "./messaging/delete-overlays.css";
|
||||
@import "./messaging/message-timeline.css";
|
||||
@import "./messaging/tool-call.css";
|
||||
@import "./messaging/log-view.css";
|
||||
@@ -110,4 +112,3 @@
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
|
||||
35
packages/ui/src/styles/messaging/delete-overlays.css
Normal file
35
packages/ui/src/styles/messaging/delete-overlays.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* Hover overlays for destructive actions (delete part / delete message). */
|
||||
|
||||
.message-stream-block[data-delete-message-hover="true"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-stream-block[data-delete-message-hover="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: var(--status-error-bg);
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
/* Overlay must sit above the message cards (they have opaque backgrounds). */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.message-part-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.delete-hover-scope[data-delete-part-hover="true"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.delete-hover-scope[data-delete-part-hover="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: var(--status-error-bg);
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
/* Overlay must sit above the part card background. */
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
}
|
||||
|
||||
.message-item-header {
|
||||
@apply flex flex-col gap-0.5;
|
||||
@apply flex flex-col;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message-item-header-row {
|
||||
@@ -19,12 +20,58 @@
|
||||
@apply flex justify-between items-start gap-2.5;
|
||||
}
|
||||
|
||||
.message-item-header-row--meta {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.message-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-item-header-row--bottom {
|
||||
@apply flex items-start;
|
||||
}
|
||||
|
||||
.message-speaker {
|
||||
@apply flex flex-col gap-0.5 text-xs;
|
||||
/* Allow agent meta to wrap to a second row with comfortable spacing. */
|
||||
@apply flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-speaker-primary {
|
||||
@apply inline-flex items-center;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-agent-meta-inline {
|
||||
@apply text-[11px] font-medium;
|
||||
color: var(--message-assistant-border);
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.message-agent-meta-inline--measure {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-agent-meta-block {
|
||||
@apply text-[11px] font-medium;
|
||||
color: var(--message-assistant-border);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.message-speaker-label {
|
||||
@@ -46,19 +93,19 @@
|
||||
|
||||
.message-item-actions {
|
||||
@apply flex items-center gap-2;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-action-group {
|
||||
@apply flex items-center gap-2;
|
||||
@apply flex items-center gap-0;
|
||||
}
|
||||
|
||||
.message-action-button {
|
||||
@apply bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6;
|
||||
@apply bg-transparent border-0 text-[var(--text-muted)] cursor-pointer px-2 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6;
|
||||
}
|
||||
|
||||
.message-action-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
@@ -296,6 +343,12 @@
|
||||
color: var(--message-assistant-border);
|
||||
}
|
||||
|
||||
/* Keep reasoning meta as a single unit so it drops to the next line when needed. */
|
||||
.message-reasoning-label .message-step-meta-inline {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.message-step-reason {
|
||||
@apply text-[11px] font-medium;
|
||||
@@ -320,7 +373,7 @@
|
||||
|
||||
.message-reasoning-header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -365,11 +418,36 @@
|
||||
}
|
||||
|
||||
.message-reasoning-label {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--message-assistant-border);
|
||||
}
|
||||
|
||||
.message-reasoning-label-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-step-meta-inline--measure {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-reasoning-meta-row {
|
||||
padding: 0 0.6rem 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
.message-reasoning-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
84
packages/ui/src/styles/messaging/message-selection.css
Normal file
84
packages/ui/src/styles/messaging/message-selection.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* Message multi-select delete mode UI. */
|
||||
|
||||
.message-select-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--status-error);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-delete-mode-toolbar {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
background: color-mix(in oklab, var(--surface-secondary) 92%, var(--status-error-bg));
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 12px;
|
||||
z-index: 50;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Avoid covering the scroll-to-top/bottom floating buttons. */
|
||||
.message-layout[data-scroll-buttons="1"] .message-delete-mode-toolbar {
|
||||
bottom: 4.25rem;
|
||||
}
|
||||
|
||||
.message-layout[data-scroll-buttons="2"] .message-delete-mode-toolbar {
|
||||
bottom: 7.5rem;
|
||||
}
|
||||
|
||||
/* When timeline is visible, pin the toolbar to the stream edge. */
|
||||
.message-layout--with-timeline .message-delete-mode-toolbar {
|
||||
right: calc(64px + 12px);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.message-layout--with-timeline .message-delete-mode-toolbar {
|
||||
right: calc(40px + 12px);
|
||||
}
|
||||
}
|
||||
|
||||
.message-delete-mode-count {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.message-delete-mode-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.message-delete-mode-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--status-error);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.message-delete-mode-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent);
|
||||
}
|
||||
@@ -87,6 +87,7 @@
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -98,6 +99,35 @@
|
||||
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-timeline-segment[data-delete-hover="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--status-error-bg);
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Ensure delete hover is visible even on the active segment styling. */
|
||||
.message-timeline-segment[data-delete-hover="true"],
|
||||
.message-timeline-segment[data-delete-hover="true"]:hover,
|
||||
.message-timeline-segment[data-delete-hover="true"]:focus-visible,
|
||||
.message-timeline-segment-active[data-delete-hover="true"],
|
||||
.message-timeline-segment-active[data-delete-hover="true"]:hover,
|
||||
.message-timeline-segment-active[data-delete-hover="true"]:focus-visible {
|
||||
/* Let the ::before overlay provide the highlight (matches stream behavior). */
|
||||
background-color: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.message-timeline-label {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.message-timeline-segment.message-timeline-segment-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
.tool-call-header-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--tool-call-border-color, var(--border-base));
|
||||
color: var(--text-secondary);
|
||||
padding: 0.15rem 0.75rem;
|
||||
border: 0;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -37,7 +37,6 @@
|
||||
|
||||
.tool-call-header-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
|
||||
4
packages/ui/src/types/delete-hover.ts
Normal file
4
packages/ui/src/types/delete-hover.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type DeleteHoverState =
|
||||
| { kind: "none" }
|
||||
| { kind: "message"; messageId: string }
|
||||
| { kind: "deleteUpTo"; messageId: string }
|
||||
@@ -1,4 +1,4 @@
|
||||
// SDK types
|
||||
// SDK v2 types
|
||||
import type {
|
||||
EventMessageUpdated as MessageUpdateEvent,
|
||||
EventMessageRemoved as MessageRemovedEvent,
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
EventMessagePartRemoved as MessagePartRemovedEvent,
|
||||
Part as SDKPart,
|
||||
Message as SDKMessage,
|
||||
} from "@opencode-ai/sdk"
|
||||
AssistantMessage as SDKAssistantMessageV2,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
|
||||
import type { PermissionRequestLike } from "./permission"
|
||||
|
||||
@@ -17,7 +18,8 @@ export type {
|
||||
MessagePartUpdatedEvent,
|
||||
MessagePartRemovedEvent,
|
||||
SDKPart,
|
||||
SDKMessage
|
||||
SDKMessage,
|
||||
SDKAssistantMessageV2,
|
||||
}
|
||||
|
||||
// Server streaming event: append-only delta updates.
|
||||
|
||||
Reference in New Issue
Block a user