diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml
index 8ddc9daa..392db41d 100644
--- a/.github/workflows/build-and-upload.yml
+++ b/.github/workflows/build-and-upload.yml
@@ -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,112 @@ 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: |
+ set -euo pipefail
+
+ # Prefer the workflow-provided version; fall back to package.json.
+ VERSION_TO_USE="${VERSION:-}"
+ if [ -z "$VERSION_TO_USE" ]; then
+ VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
+ fi
+
+ release_root="packages/electron-app/release"
+ # macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
+ # Use find to locate built app bundles instead of ** globs.
+ 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
+
+ for app in "${apps[@]}"; do
+ bundle_dir=$(basename "$(dirname "$app")")
+ arch="x64"
+ if [[ "$bundle_dir" == *"arm64"* ]]; then
+ arch="arm64"
+ fi
+
+ out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
+ rm -f "$out_zip"
+ echo "ditto -ck: $app -> $out_zip"
+ ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
+ done
+
+ - name: Validate Electron macOS codesign (unzipped)
+ shell: bash
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+
+ tmp_dir=$(mktemp -d)
+ trap 'rm -rf "$tmp_dir"' EXIT
+
+ zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
+ if [ "${#zips[@]}" -eq 0 ]; then
+ echo "No Electron macOS zip artifacts found to validate" >&2
+ exit 1
+ fi
+
+ for zip in "${zips[@]}"; do
+ echo "Validating codesign for: $zip"
+ extract_dir="$tmp_dir/$(basename "$zip" .zip)"
+ mkdir -p "$extract_dir"
+
+ # Use ditto for extraction as well to preserve bundle metadata.
+ ditto -x -k "$zip" "$extract_dir"
+
+ app_path=""
+ for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
+ if [ -d "$candidate" ]; then
+ app_path="$candidate"
+ break
+ fi
+ done
+
+ if [ -z "$app_path" ]; then
+ echo "No .app found after extracting $zip" >&2
+ exit 1
+ fi
+
+ codesign --verify --deep --strict --verbose=2 "$app_path"
+ done
+
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
diff --git a/package-lock.json b/package-lock.json
index 99303498..d8ab5dee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
- "version": "0.11.4",
+ "version": "0.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
- "version": "0.11.4",
+ "version": "0.12.1",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -11985,7 +11985,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
- "version": "0.11.4",
+ "version": "0.12.1",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -11995,6 +11995,7 @@
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
+ "cross-env": "^7.0.3",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
@@ -12021,7 +12022,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
- "version": "0.11.4",
+ "version": "0.12.1",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12062,7 +12063,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
- "version": "0.11.4",
+ "version": "0.12.1",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12070,7 +12071,7 @@
},
"packages/ui": {
"name": "@codenomad/ui",
- "version": "0.11.4",
+ "version": "0.12.1",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
diff --git a/package.json b/package.json
index e2381e85..18ee0d30 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
- "version": "0.11.4",
+ "version": "0.12.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json
index 299fb24c..37efb960 100644
--- a/packages/cloudflare/release-config.json
+++ b/packages/cloudflare/release-config.json
@@ -1,4 +1,4 @@
{
- "minServerVersion": "0.11.1",
+ "minServerVersion": "0.11.4",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}
diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts
index a9b940c0..de00d1f5 100644
--- a/packages/electron-app/electron/main/process-manager.ts
+++ b/packages/electron-app/electron/main/process-manager.ts
@@ -431,7 +431,9 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
- args.push("--ui-dev-server", devServer, "--log-level", "debug")
+ const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
+ const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
+ args.push("--ui-dev-server", devServer, "--log-level", logLevel)
}
return args
diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json
index b1918510..48f10b14 100644
--- a/packages/electron-app/package.json
+++ b/packages/electron-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
- "version": "0.11.4",
+ "version": "0.12.1",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
@@ -15,7 +15,10 @@
},
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": {
- "dev": "electron-vite dev",
+ "dev": "npm run dev:info",
+ "dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
+ "dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
+ "dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
@@ -42,6 +45,7 @@
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
+ "cross-env": "^7.0.3",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json
index 4c3ce075..82ed1e6d 100644
--- a/packages/opencode-config/package.json
+++ b/packages/opencode-config/package.json
@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
- "@opencode-ai/plugin": "1.2.10"
+ "@opencode-ai/plugin": "1.2.14"
}
}
\ No newline at end of file
diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json
index 1755c004..a0571f3b 100644
--- a/packages/server/package-lock.json
+++ b/packages/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
- "version": "0.11.4",
+ "version": "0.12.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
- "version": "0.11.4",
+ "version": "0.12.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
diff --git a/packages/server/package.json b/packages/server/package.json
index 02082c24..2e9cc3ac 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
- "version": "0.11.4",
+ "version": "0.12.1",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json
index b86075f5..4a05a8e2 100644
--- a/packages/tauri-app/package.json
+++ b/packages/tauri-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
- "version": "0.11.4",
+ "version": "0.12.1",
"private": true,
"license": "MIT",
"scripts": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 50ab2cdd..d8fbf761 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
- "version": "0.11.4",
+ "version": "0.12.1",
"private": true,
"license": "MIT",
"type": "module",
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
index 62caf483..cee2ab06 100644
--- a/packages/ui/src/App.tsx
+++ b/packages/ui/src/App.tsx
@@ -496,17 +496,24 @@ const App: Component = () => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
-
-
- handleCloseSession(instance.id, sessionId)}
- onNewSession={() => handleNewSession(instance.id)}
- handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
- handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
- onExecuteCommand={executeCommand}
+
+
+ handleCloseSession(instance.id, sessionId)}
+ onNewSession={() => handleNewSession(instance.id)}
+ handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
+ handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
+ onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
index c6694f80..ace89d5f 100644
--- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
+++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
@@ -61,6 +61,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4,
lineDecorationsWidth: 12,
+ // Use legacy diff algorithm for better performance with large files
+ // See: https://github.com/microsoft/vscode/issues/184037
+ diffAlgorithm: "legacy",
+ // Limit computation time to avoid freezing on large files
+ maxComputationTime: 10000,
})
setReady(true)
diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx
index 43998a17..84e55578 100644
--- a/packages/ui/src/components/instance/instance-shell2.tsx
+++ b/packages/ui/src/components/instance/instance-shell2.tsx
@@ -62,6 +62,9 @@ const log = getLogger("session")
interface InstanceShellProps {
instance: Instance
+ // Provided by App-level instance tabs; lets us pause heavy rendering
+ // work for inactive instances while keeping them mounted for fast switching.
+ isActiveInstance?: boolean
escapeInDebounce: boolean
paletteCommands: Accessor
onCloseSession: (sessionId: string) => Promise | void
@@ -115,6 +118,7 @@ const InstanceShell2: Component = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)")
const tabletQuery = useMediaQuery("(min-width: 768px)")
+ const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
const layoutMode = createMemo(() => {
if (desktopQuery()) return "desktop"
@@ -123,6 +127,7 @@ const InstanceShell2: Component = (props) => {
})
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
+ const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -596,7 +601,7 @@ const InstanceShell2: Component = (props) => {
@@ -634,8 +639,8 @@ const InstanceShell2: Component = (props) => {
-
-
+
+
= (props) => {
-
+
= (props) => {
{rightAppBarButtonIcon()}
-
+
-
+
+
+
}
@@ -796,12 +803,14 @@ const InstanceShell2: Component = (props) => {
>
{(sessionId) => {
- const isActive = () => activeSessionIdForInstance() === sessionId
+ const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
return (
= (props) => {
return (
<>
-
+
}>
{sessionLayout}
diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
index fc0e58aa..a4168973 100644
--- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
@@ -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"
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx
index a4fcd46a..2062b1c7 100644
--- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx
@@ -1,4 +1,4 @@
-import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
+import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
@@ -32,14 +32,18 @@ interface ChangesTabProps {
}
const ChangesTab: Component
= (props) => {
- const renderContent = (): JSX.Element => {
- const sessionId = props.activeSessionId()
+ const sessionId = createMemo(() => props.activeSessionId())
+ const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
+ const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
- const hasSession = Boolean(sessionId && sessionId !== "info")
- const diffs = hasSession ? props.activeSessionDiffs() : null
+ const sorted = createMemo(() => {
+ const list = diffs()
+ if (!Array.isArray(list)) return []
+ return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
+ })
- const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
- const totals = sorted.reduce(
+ const totals = createMemo(() => {
+ return sorted().reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
@@ -47,41 +51,61 @@ const ChangesTab: Component = (props) => {
},
{ additions: 0, deletions: 0 },
)
+ })
- const mostChanged = sorted.length
- ? sorted.reduce((best, item) => {
- const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
- const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
- const bestScore = bestAdd + bestDel
+ const mostChanged = createMemo(() => {
+ const items = sorted()
+ if (items.length === 0) return null
+ return items.reduce((best, item) => {
+ const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
+ const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
+ const bestScore = bestAdd + bestDel
- const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
- const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
- const score = add + del
+ const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
+ const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
+ const score = add + del
- if (score > bestScore) return item
- if (score < bestScore) return best
- return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
- }, sorted[0])
- : null
+ if (score > bestScore) return item
+ if (score < bestScore) return best
+ return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
+ }, items[0])
+ })
- // Auto-select the most-changed file if none selected.
+ const selectedFileData = createMemo(() => {
const currentSelected = props.selectedFile()
- const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
-
- const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
-
- const emptyViewerMessage = () => {
- if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
- if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
- if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
- return props.t("instanceShell.filesShell.viewerEmpty")
+ const items = sorted()
+ if (currentSelected) {
+ const match = items.find((f) => f.file === currentSelected)
+ if (match) return match
}
+ return mostChanged()
+ })
+
+ const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
+
+ const emptyViewerMessage = createMemo(() => {
+ if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
+ const currentDiffs = diffs()
+ if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
+ if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
+ return props.t("instanceShell.filesShell.viewerEmpty")
+ })
+
+ const headerPath = createMemo(() => {
+ const file = selectedFileData()
+ return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
+ })
+
+ const renderContent = (): JSX.Element => {
+ const sortedList = sorted()
+ const totalsValue = totals()
+ const selected = selectedFileData()
const renderViewer = () => (
0 ? selectedFileData : null}
+ when={selected && hasSession() && sortedList.length > 0 ? selected : null}
fallback={
{emptyViewerMessage()}
@@ -90,7 +114,7 @@ const ChangesTab: Component
= (props) => {
>
{(file) => (
= (props) => {
)
const renderListPanel = () => (
- 0} fallback={renderEmptyList()}>
-
+ 0} fallback={renderEmptyList()}>
+
{(item) => (
{
props.onSelectFile(item.file, props.isPhoneLayout())
}}
@@ -134,11 +158,11 @@ const ChangesTab: Component
= (props) => {
)
const renderListOverlay = () => (
- 0} fallback={renderEmptyList()}>
-
+ 0} fallback={renderEmptyList()}>
+
{(item) => (
{
props.onSelectFile(item.file, true)
}}
@@ -159,8 +183,6 @@ const ChangesTab: Component
= (props) => {
)
- const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
-
return (
= (props) => {
- +{totals.additions}
+ +{totalsValue.additions}
- -{totals.deletions}
+ -{totalsValue.deletions}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx
index 4575f1e5..b2ab7ff4 100644
--- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx
@@ -1,4 +1,4 @@
-import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
+import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
@@ -46,17 +46,18 @@ interface GitChangesTabProps {
}
const GitChangesTab: Component = (props) => {
- const renderContent = (): JSX.Element => {
- const sessionId = props.activeSessionId()
+ const sessionId = createMemo(() => props.activeSessionId())
+ const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
+ const entries = createMemo(() => (hasSession() ? props.entries() : null))
- const hasSession = Boolean(sessionId && sessionId !== "info")
- const entries = hasSession ? props.entries() : null
+ const sorted = createMemo(() => {
+ const list = entries()
+ if (!Array.isArray(list)) return []
+ return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
+ })
- const sorted = Array.isArray(entries)
- ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
- : []
-
- const totals = sorted.reduce(
+ const totals = createMemo(() => {
+ return sorted().reduce(
(acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0
@@ -64,21 +65,33 @@ const GitChangesTab: Component = (props) => {
},
{ additions: 0, deletions: 0 },
)
+ })
- const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
-
- const emptyViewerMessage = () => {
- if (!hasSession) return "Select a session to view changes."
- if (entries === null) return "Loading git changes…"
- if (nonDeleted.length === 0) return "No git changes yet."
- return "No file selected."
- }
+ const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
+ const selectedEntry = createMemo(() => {
+ const list = sorted()
const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
- const selectedEntry =
- sorted.find((item) => item.path === selectedPath) ||
- (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
+ const found =
+ list.find((item) => item.path === selectedPath) ||
+ (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
+ return found ?? null
+ })
+
+ const emptyViewerMessage = createMemo(() => {
+ if (!hasSession()) return "Select a session to view changes."
+ const currentEntries = entries()
+ if (currentEntries === null) return "Loading git changes…"
+ if (nonDeleted().length === 0) return "No git changes yet."
+ return "No file selected."
+ })
+
+ const renderContent = (): JSX.Element => {
+ const totalsValue = totals()
+ const selected = selectedEntry()
+ const sortedList = sorted()
+ const nonDeletedList = nonDeleted()
const renderViewer = () => (
@@ -91,12 +104,12 @@ const GitChangesTab: Component
= (props) => {
fallback={
= (props) => {
}
>
{(file) => (
-
- )}
+
+ )}
}
>
@@ -141,8 +154,8 @@ const GitChangesTab: Component = (props) => {
const renderEmptyList = () => {emptyViewerMessage()}
const renderListPanel = () => (
- 0} fallback={renderEmptyList()}>
-
+ 0} fallback={renderEmptyList()}>
+
{(item) => (
= (props) => {
)
const renderListOverlay = () => (
-
0} fallback={renderEmptyList()}>
-
+ 0} fallback={renderEmptyList()}>
+
{(item) => (
= (props) => {
)
return (
-
-
- {selectedEntry?.path || "Git Changes"}
-
+
+
+ {selected?.path || "Git Changes"}
+
- +{totals.additions}
+ +{totalsValue.additions}
- -{totals.deletions}
+ -{totalsValue.deletions}
{(err) => {err()}}
@@ -226,23 +239,23 @@ const GitChangesTab: Component = (props) => {
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
- disabled={!hasSession || props.statusLoading() || entries === null}
+ disabled={!hasSession() || props.statusLoading() || entries() === null}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
-
- >
- }
+
+ >
+ }
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
index e6839af6..b52c16d9 100644
--- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
@@ -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"
diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts
index faee193a..5eb98bf2 100644
--- a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts
+++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts
@@ -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,
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 39638e1b..a4908f16 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -92,7 +92,6 @@ export function Markdown(props: MarkdownProps) {
const globalCache = cacheHandle.get()
if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html)
- part.renderCache = globalCache
notifyRendered()
return
}
@@ -100,14 +99,11 @@ export function Markdown(props: MarkdownProps) {
const commitCacheEntry = (renderedHtml: string) => {
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
- part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
if (!highlightEnabled) {
- part.renderCache = undefined
-
try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
@@ -185,7 +181,6 @@ export function Markdown(props: MarkdownProps) {
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
- part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
@@ -202,5 +197,15 @@ export function Markdown(props: MarkdownProps) {
const proseClass = () => "markdown-body"
- return
+ return (
+
+ )
}
diff --git a/packages/ui/src/components/message-anchors.ts b/packages/ui/src/components/message-anchors.ts
new file mode 100644
index 00000000..266ce5ff
--- /dev/null
+++ b/packages/ui/src/components/message-anchors.ts
@@ -0,0 +1,9 @@
+export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
+
+export function getMessageAnchorId(messageId: string) {
+ return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
+}
+
+export function getMessageIdFromAnchorId(anchorId: string) {
+ return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
+}
diff --git a/packages/ui/src/components/message-block-list.tsx b/packages/ui/src/components/message-block-list.tsx
deleted file mode 100644
index 3db083cc..00000000
--- a/packages/ui/src/components/message-block-list.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-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"
-
-export function getMessageAnchorId(messageId: string) {
- return `message-anchor-${messageId}`
-}
-
-const VIRTUAL_ITEM_MARGIN_PX = 800
-
-interface MessageBlockListProps {
- instanceId: string
- sessionId: string
- store: () => InstanceMessageStore
- messageIds: () => string[]
- lastAssistantIndex: () => number
- showThinking: () => boolean
- thinkingDefaultExpanded: () => boolean
- showUsageMetrics: () => boolean
- scrollContainer: Accessor
- loading?: boolean
- onRevert?: (messageId: string) => void
- onFork?: (messageId?: string) => void
- onContentRendered?: () => void
- setBottomSentinel: (element: HTMLDivElement | null) => void
- suspendMeasurements?: () => boolean
-}
-
-export default function MessageBlockList(props: MessageBlockListProps) {
- return (
- <>
-
- {(messageId, index) => (
- !props.loading}
- suspendMeasurements={props.suspendMeasurements}
- >
-
-
- )}
-
-
- >
- )
-}
diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx
index 0d485c94..9b518435 100644
--- a/packages/ui/src/components/message-block.tsx
+++ b/packages/ui/src/components/message-block.tsx
@@ -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 (
+
+
+
+ )
+}
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
-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
onFork?: (messageId?: string) => void
onContentRendered?: () => void
+ showDeleteMessage?: boolean
+ onDeleteHoverChange?: (state: DeleteHoverState) => void
+ selectedMessageIds?: () => Set
+ 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,41 @@ interface ToolCallItemProps {
messageId: string
partId: string
onContentRendered?: () => void
+ showDeleteMessage?: boolean
+ deleteHover?: () => DeleteHoverState
+ onDeleteHoverChange?: (state: DeleteHoverState) => void
+ onDeleteMessagesUpTo?: (messageId: string) => void | Promise
+ selectedMessageIds?: () => Set
+ selectedToolPartKeys?: () => Set
+ 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 isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`))
+
+ const isDeleteOverlayActive = () => {
+ if (isSelectedForDeletion()) return true
+ if (isSelectedToolPartForDeletion()) return true
+ const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
+ if (hover.kind === "message") {
+ return hover.messageId === props.messageId
+ }
+ if (hover.kind === "deleteUpTo") {
+ const ids = props.store().getSessionMessageIds(props.sessionId)
+ const targetIndex = ids.indexOf(hover.messageId)
+ if (targetIndex === -1) return false
+ const currentIndex = ids.indexOf(props.messageId)
+ if (currentIndex === -1) return false
+ return currentIndex >= targetIndex
+ }
+ return false
+ }
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -319,14 +368,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 +391,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 (
{(resolvedToolPart) => (
- <>
+
)}
)
@@ -470,7 +562,13 @@ interface MessageBlockProps {
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
+ deleteHover?: () => DeleteHoverState
+ onDeleteHoverChange?: (state: DeleteHoverState) => void
+ selectedMessageIds?: () => Set
+ selectedToolPartKeys?: () => Set
+ onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void
+ onDeleteMessagesUpTo?: (messageId: string) => void | Promise
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
@@ -481,6 +579,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()
+ 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(() => {
const current = record()
if (!current) return null
@@ -668,9 +790,13 @@ export default function MessageBlock(props: MessageBlockProps) {
return (
{(resolvedBlock) => (
-
+
- {(item) => (
+ {(item, index) => (
@@ -697,6 +828,13 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
+ showDeleteMessage={index() === 0}
+ deleteHover={props.deleteHover}
+ onDeleteHoverChange={props.onDeleteHoverChange}
+ onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
+ selectedMessageIds={props.selectedMessageIds}
+ selectedToolPartKeys={props.selectedToolPartKeys}
+ onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
@@ -709,6 +847,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}
/>
@@ -718,6 +864,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}
/>
@@ -728,7 +882,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}
/>
@@ -738,9 +896,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}
/>
@@ -759,6 +921,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
+ selectedMessageIds?: () => Set
+ onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
}
interface CompactionCardProps {
@@ -768,12 +938,18 @@ interface CompactionCardProps {
instanceId: string
sessionId: string
messageId: string
- partId: string
+ showDeleteMessage?: boolean
+ onDeleteHoverChange?: (state: DeleteHoverState) => void
+ onDeleteMessagesUpTo?: (messageId: string) => void | Promise
+ selectedMessageIds?: () => Set
+ 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 +957,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 (
-
+
+
+
+
+
+
+
+
+ {
+ 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")}
+ />
+
+
{label()}
@@ -828,6 +1058,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 +1105,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
>) => {
const entries = [
@@ -902,17 +1171,83 @@ function StepCard(props: StepCardProps) {
return null
}
return (
-
+
+
+ {
+ 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")}
+ />
+
+
+
+
+
+
+
+
+
+
{renderUsageChips(usage)}
)
}
return (
-
+
+
+ {
+ 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")}
+ />
+
+
{(value) => {t("messageBlock.step.agentLabel", { agent: value() })}}
@@ -939,15 +1274,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
+ selectedMessageIds?: () => Set
+ 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 +1321,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 +1390,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 (
-
-