Compare commits

..

16 Commits

Author SHA1 Message Date
Shantur Rathore
0e5695a903 ui: emphasize command palette button 2026-02-20 00:24:24 +00:00
Shantur Rathore
77103b7292 ui: use Check icon for completed status 2026-02-20 00:14:02 +00:00
Shantur Rathore
b14a144ddc ui: use lucide status icons for tool calls 2026-02-20 00:08:07 +00:00
Shantur Rathore
8ac67311d8 ui: use emoji status icons for tool calls 2026-02-19 23:51:33 +00:00
Shantur Rathore
0c97db393c fix(ui): expand read tool calls on error 2026-02-19 21:16:14 +00:00
Shantur Rathore
614c300d2f ui: default tool input visibility to collapsed 2026-02-19 21:12:39 +00:00
Shantur Rathore
e6ca4bd43d fix(ui): let palette tool input visibility override per-call 2026-02-19 20:46:46 +00:00
Shantur Rathore
84f81cf829 ui: left-align tool IO header text 2026-02-19 20:44:22 +00:00
Shantur Rathore
3760ba2d7f fix(ui): scope tool input toggle to current tool call 2026-02-19 20:42:23 +00:00
Shantur Rathore
09e7a3f8da feat(ui): add tool input visibility preference 2026-02-19 20:37:48 +00:00
Shantur Rathore
c55d56c94b ui: remove semibold from IO headers 2026-02-19 18:44:57 +00:00
Shantur Rathore
cc53123bcd ui: remove extra padding around IO sections 2026-02-19 18:44:29 +00:00
Shantur Rathore
d64027d43d ui: refine tool IO accordion styling 2026-02-19 18:43:06 +00:00
Shantur Rathore
6b7162f50f ui: add input/output accordions in tool calls 2026-02-19 18:37:46 +00:00
Shantur Rathore
5fd985f0c2 ui: rename tool input toggle and add IO headers 2026-02-19 18:31:41 +00:00
Shantur Rathore
2a438b2bb3 feat(ui): toggle tool call input yaml 2026-02-19 18:09:16 +00:00
88 changed files with 1832 additions and 5772 deletions

View File

@@ -3,11 +3,6 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -50,8 +45,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -61,21 +54,7 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
shell: bash run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
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 - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@@ -86,112 +65,6 @@ jobs:
- name: Build macOS binaries (Electron) - name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app 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 - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -212,8 +85,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -253,8 +124,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -295,8 +164,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -370,8 +237,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -445,8 +310,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -525,8 +388,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -629,8 +490,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -728,8 +587,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,13 +1,12 @@
name: Develop Pre-Release name: Develop Pre-Release
on: on:
schedule: push:
# Nightly build of dev (only if dev has new commits) branches:
- cron: "0 1 * * *" - dev
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
id-token: write id-token: write
contents: write contents: write
@@ -16,63 +15,25 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
gate: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
run: ${{ steps.gate.outputs.run }} version_suffix: ${{ steps.vars.outputs.version_suffix }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps: steps:
- name: Decide whether to run - name: Compute version suffix
id: gate id: vars
shell: bash shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
SHA8="${GITHUB_SHA::8}"
api() {
curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$1"
}
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
echo "Failed to resolve dev head SHA" >&2
exit 1
fi
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
SHA8="${DEV_SHA::8}" echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
SHOULD_RUN="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
SHOULD_RUN="true"
else
# Nightly: only run if dev has advanced since last successful dev-release build.
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
if [ -z "${LAST_SHA}" ]; then
SHOULD_RUN="true"
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
SHOULD_RUN="true"
fi
fi
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
prerelease: prerelease:
needs: gate needs: prepare
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
ref: ${{ needs.gate.outputs.dev_sha }} version_suffix: ${{ needs.prepare.outputs.version_suffix }}
version_suffix: ${{ needs.gate.outputs.version_suffix }}
npm_package_name: "@neuralnomads/codenomad-dev" npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest dist_tag: latest
prerelease: true prerelease: true

View File

@@ -19,10 +19,6 @@ on:
type: string type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -50,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,13 +1,7 @@
name: Release UI name: Release UI
on: on:
workflow_call: workflow_call: {}
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@@ -24,8 +18,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -3,11 +3,6 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -51,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -91,7 +84,6 @@ jobs:
needs: prepare-release needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }} tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }} release_name: ${{ needs.prepare-release.outputs.release_name }}
@@ -103,8 +95,6 @@ jobs:
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
with:
ref: ${{ inputs.ref || github.ref }}
secrets: inherit secrets: inherit
publish-server: publish-server:
@@ -113,7 +103,6 @@ jobs:
- build-and-upload - build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }} package_name: ${{ inputs.npm_package_name }}

13
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.1", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.1", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -11985,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.1", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -11995,7 +11995,6 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
@@ -12022,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.1", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12063,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.12.1", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12071,7 +12070,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.1", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.1", "version": "0.11.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.11.4", "minServerVersion": "0.11.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -431,9 +431,7 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) { if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim() args.push("--ui-dev-server", devServer, "--log-level", "debug")
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
} }
return args return args

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.1", "version": "0.11.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -15,10 +15,7 @@
}, },
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "npm run dev:info", "dev": "electron-vite dev",
"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", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -45,7 +42,6 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.14" "@opencode-ai/plugin": "1.2.6"
} }
} }

View File

@@ -5,21 +5,18 @@
## Features & Capabilities ## Features & Capabilities
### 🌍 Deployment Freedom ### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. - **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. - **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. - **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect. - **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power ### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches. - **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts. - **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites ## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system. - **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source). - Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve. - A workspace folder on disk you want to serve.
@@ -28,7 +25,6 @@
## Usage ## Usage
### Run via npx (Recommended) ### Run via npx (Recommended)
You can run CodeNomad directly without installing it: You can run CodeNomad directly without installing it:
```sh ```sh
@@ -47,7 +43,6 @@ On startup, CodeNomad prints two URLs:
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) - `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally ### Install Globally
Or install it globally to use the `codenomad` command: Or install it globally to use the `codenomad` command:
```sh ```sh
@@ -56,7 +51,6 @@ codenomad --launch
``` ```
### Install Locally (per-project) ### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary: If you prefer to install CodeNomad into a project and run the local binary:
```sh ```sh
@@ -67,7 +61,6 @@ npx codenomad --launch
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.) (`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags ### Common Flags
You can configure the server using flags or environment variables: You can configure the server using flags or environment variables:
| Flag | Env Variable | Description | | Flag | Env Variable | Description |
@@ -81,7 +74,7 @@ You can configure the server using flags or environment variables:
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
@@ -94,11 +87,10 @@ You can configure the server using flags or environment variables:
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` | | `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | | `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced) ### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh ```sh
@@ -149,14 +141,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
``` ```
### Authentication ### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA) ### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
@@ -168,6 +158,5 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). > If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.1", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.1", "version": "0.11.3",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.1", "version": "0.11.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption( .addOption(
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()), new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
) )
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))

View File

@@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js" import { getOpencodeConfigDir } from "../opencode-config.js"
import { import {
@@ -109,6 +109,10 @@ export class WorkspaceManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
@@ -145,10 +149,7 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
if (runtimeVersion) {
descriptor.binaryVersion = runtimeVersion
}
descriptor.pid = pid descriptor.pid = pid
descriptor.port = port descriptor.port = port
@@ -277,12 +278,36 @@ export class WorkspaceManager {
return candidates[0] ?? "" return candidates[0] ?? ""
} }
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
const result = probeBinaryVersion(resolvedPath)
if (result.valid) {
if (result.version) {
this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
return result.version
}
if (result.reported) {
this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
return result.reported
}
return undefined
}
if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: { private async waitForWorkspaceReadiness(params: {
workspaceId: string workspaceId: string
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
await Promise.race([ await Promise.race([
this.waitForPortAvailability(params.port), this.waitForPortAvailability(params.port),
@@ -296,7 +321,7 @@ export class WorkspaceManager {
}), }),
]) ])
const version = await this.waitForInstanceHealth(params) await this.waitForInstanceHealth(params)
await Promise.race([ await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS), this.delay(STARTUP_STABILITY_DELAY_MS),
@@ -309,8 +334,6 @@ export class WorkspaceManager {
) )
}), }),
]) ])
return version
} }
private async waitForInstanceHealth(params: { private async waitForInstanceHealth(params: {
@@ -318,7 +341,7 @@ export class WorkspaceManager {
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
const probeResult = await Promise.race([ const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port), this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => { params.exitPromise.then((info) => {
@@ -332,7 +355,7 @@ export class WorkspaceManager {
]) ])
if (probeResult.ok) { if (probeResult.ok) {
return probeResult.version return
} }
const latestOutput = params.getLastOutput().trim() const latestOutput = params.getLastOutput().trim()
@@ -343,11 +366,8 @@ export class WorkspaceManager {
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
} }
private async probeInstance( private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
workspaceId: string, const url = `http://127.0.0.1:${port}/project/current`
port: number,
): Promise<{ ok: boolean; reason?: string; version?: string }> {
const url = `http://127.0.0.1:${port}/global/health`
try { try {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -358,22 +378,11 @@ export class WorkspaceManager {
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `/global/health returned HTTP ${response.status}` const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason } return { ok: false, reason }
} }
return { ok: true }
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
const healthy = payload?.healthy === true
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
if (!healthy) {
const reason = "Instance reported unhealthy"
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
return { ok: false, reason }
}
return { ok: true, version: version || undefined }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error) const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.12.1", "version": "0.11.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.1", "version": "0.11.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -18,8 +18,6 @@ import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
@@ -77,6 +75,12 @@ const App: Component = () => {
setToolInputsVisibility, setToolInputsVisibility,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
@@ -241,6 +245,35 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? "" const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) { if (!folderPath) {
return return
@@ -259,9 +292,13 @@ const App: Component = () => {
port: instances().get(instanceId)?.port, port: instances().get(instanceId)?.port,
}) })
} catch (error) { } catch (error) {
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message) const missingBinary = isMissingBinaryMessage(message)
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error) log.error("Failed to create instance", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
@@ -496,24 +533,17 @@ const App: Component = () => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection() const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div <div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
class="flex-1 min-h-0 overflow-hidden" <InstanceMetadataProvider instance={instance}>
style={{ display: isVisible() ? "flex" : "none" }} <InstanceShell
data-instance-id={instance.id} instance={instance}
data-instance-active={isActiveInstance() ? "true" : "false"} escapeInDebounce={escapeInDebounce()}
data-instance-visible={isVisible() ? "true" : "false"} paletteCommands={paletteCommands}
> onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
<InstanceMetadataProvider instance={instance}> onNewSession={() => handleNewSession(instance.id)}
<InstanceShell handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
instance={instance} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
isActiveInstance={isActiveInstance()} onExecuteCommand={executeCommand}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => 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()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()} onEnterMobileFullscreen={() => void enterMobileFullscreen()}

View File

@@ -116,8 +116,11 @@ const AlertDialog: Component = () => {
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}> <Dialog.Content
<div class="flex items-start gap-3"> class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
tabIndex={-1}
>
<div class="flex items-start gap-3 min-h-0">
<div <div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold" class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{ style={{
@@ -129,11 +132,16 @@ const AlertDialog: Component = () => {
> >
{accent.symbol} {accent.symbol}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 min-h-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title> <Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words"> <Dialog.Description class="text-sm text-secondary mt-1">
{payload.message} <div
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>} class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
style={{ "overflow-wrap": "anywhere" }}
>
{payload.message}
{payload.detail && <div class="mt-3">{payload.detail}</div>}
</div>
</Dialog.Description> </Dialog.Description>
</div> </div>
</div> </div>

View File

@@ -61,11 +61,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4, lineNumbersMinChars: 4,
lineDecorationsWidth: 12, 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) setReady(true)

View File

@@ -62,9 +62,6 @@ const log = getLogger("session")
interface InstanceShellProps { interface InstanceShellProps {
instance: Instance 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 escapeInDebounce: boolean
paletteCommands: Accessor<Command[]> paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void onCloseSession: (sessionId: string) => Promise<void> | void
@@ -118,7 +115,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)") const desktopQuery = useMediaQuery("(min-width: 1280px)")
const tabletQuery = useMediaQuery("(min-width: 768px)") const tabletQuery = useMediaQuery("(min-width: 768px)")
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
const layoutMode = createMemo<LayoutMode>(() => { const layoutMode = createMemo<LayoutMode>(() => {
if (desktopQuery()) return "desktop" if (desktopQuery()) return "desktop"
@@ -127,7 +123,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}) })
const isPhoneLayout = createMemo(() => layoutMode() === "phone") const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -601,7 +596,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base"> <AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]"> <Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show <Show
when={!compactHeaderLayout()} when={!isPhoneLayout()}
fallback={ fallback={
<div class="flex flex-col w-full gap-1.5"> <div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full"> <div class="flex flex-wrap items-center justify-between gap-2 w-full">
@@ -630,7 +625,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
<button <button
type="button" type="button"
class="connection-status-button command-palette-button" class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -639,8 +634,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button> </button>
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<span <span
@@ -651,7 +646,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span> </span>
</div> </div>
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}> <Show when={!props.mobileFullscreenMode}>
<IconButton <IconButton
color="inherit" color="inherit"
onClick={props.onEnterMobileFullscreen} onClick={props.onEnterMobileFullscreen}
@@ -675,18 +670,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{rightAppBarButtonIcon()} {rightAppBarButtonIcon()}
</IconButton> </IconButton>
</Show> </Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1"> <div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<Show when={!showingInfoView()}> <ContextMeter
<ContextMeter usedTokens={tokenStats().used}
usedTokens={tokenStats().used} availableTokens={tokenStats().avail}
availableTokens={tokenStats().avail} formatTokens={formatTokenTotal}
formatTokens={formatTokenTotal} usedLabel={t("instanceShell.metrics.usedLabel")}
usedLabel={t("instanceShell.metrics.usedLabel")} availableLabel={t("instanceShell.metrics.availableLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")} />
/>
</Show>
</div> </div>
</div> </div>
} }
@@ -728,7 +721,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]"> <div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
<button <button
type="button" type="button"
class="connection-status-button command-palette-button" class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -803,14 +796,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<For each={cachedSessionIds()}> <For each={cachedSessionIds()}>
{(sessionId) => { {(sessionId) => {
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId const isActive = () => activeSessionIdForInstance() === sessionId
return ( return (
<div <div
class="session-cache-pane flex flex-col flex-1 min-h-0" class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }} style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId} data-session-id={sessionId}
data-instance-id={props.instance.id}
data-session-active={isActive() ? "true" : "false"}
aria-hidden={!isActive()} aria-hidden={!isActive()}
> >
<SessionView <SessionView
@@ -846,10 +837,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<> <>
<div <div class="instance-shell2 flex flex-col flex-1 min-h-0">
class="instance-shell2 flex flex-col flex-1 min-h-0"
data-instance-id={props.instance.id}
>
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
{sessionLayout} {sessionLayout}
</Show> </Show>

View File

@@ -7,7 +7,7 @@ import {
type Accessor, type Accessor,
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
@@ -32,18 +32,14 @@ interface ChangesTabProps {
} }
const ChangesTab: Component<ChangesTabProps> = (props) => { const ChangesTab: Component<ChangesTabProps> = (props) => {
const sessionId = createMemo(() => props.activeSessionId()) const renderContent = (): JSX.Element => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const sessionId = props.activeSessionId()
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
const sorted = createMemo<any[]>(() => { const hasSession = Boolean(sessionId && sessionId !== "info")
const list = diffs() const diffs = hasSession ? props.activeSessionDiffs() : null
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
})
const totals = createMemo(() => { const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
return sorted().reduce( const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
@@ -51,61 +47,41 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const mostChanged = createMemo<any | null>(() => { const mostChanged = sorted.length
const items = sorted() ? sorted.reduce((best, item) => {
if (items.length === 0) return null const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
return items.reduce((best, item) => { const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 const bestScore = bestAdd + bestDel
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 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 del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del const score = add + del
if (score > bestScore) return item if (score > bestScore) return item
if (score < bestScore) return best if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, items[0]) }, sorted[0])
}) : null
const selectedFileData = createMemo<any | null>(() => { // Auto-select the most-changed file if none selected.
const currentSelected = props.selectedFile() const currentSelected = props.selectedFile()
const items = sorted() const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
if (currentSelected) {
const match = items.find((f) => f.file === currentSelected) const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
if (match) return match
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")
} }
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 = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selected && hasSession() && sortedList.length > 0 ? selected : null} when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
fallback={ fallback={
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span> <span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
@@ -114,7 +90,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <MonacoDiffViewer
scopeKey={scopeKey()} scopeKey={scopeKey}
path={String(file().file || "")} path={String(file().file || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
@@ -133,11 +109,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={sortedList.length > 0} fallback={renderEmptyList()}> <Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout()) props.onSelectFile(item.file, props.isPhoneLayout())
}} }}
@@ -158,11 +134,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={sortedList.length > 0} fallback={renderEmptyList()}> <Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, true) props.onSelectFile(item.file, true)
}} }}
@@ -183,6 +159,8 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
</Show> </Show>
) )
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
@@ -193,10 +171,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totalsValue.additions}</span> <span class="files-tab-stat-value">+{totals.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totalsValue.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
@@ -46,18 +46,17 @@ interface GitChangesTabProps {
} }
const GitChangesTab: Component<GitChangesTabProps> = (props) => { const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const sessionId = createMemo(() => props.activeSessionId()) const renderContent = (): JSX.Element => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const sessionId = props.activeSessionId()
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const sorted = createMemo<GitFileStatus[]>(() => { const hasSession = Boolean(sessionId && sessionId !== "info")
const list = entries() const entries = hasSession ? props.entries() : null
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const totals = createMemo(() => { const sorted = Array.isArray(entries)
return sorted().reduce( ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
: []
const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0 acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0
@@ -65,33 +64,21 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) 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 selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath() const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath() const fallbackPath = props.mostChangedPath()
const found = const selectedEntry =
list.find((item) => item.path === selectedPath) || sorted.find((item) => item.path === selectedPath) ||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
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 = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
@@ -104,12 +91,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
fallback={ fallback={
<Show <Show
when={ when={
selected && selectedEntry &&
props.selectedBefore() !== null && props.selectedBefore() !== null &&
props.selectedAfter() !== null && props.selectedAfter() !== null &&
selected.status !== "deleted" selectedEntry.status !== "deleted"
? { ? {
path: selected.path, path: selectedEntry.path,
before: props.selectedBefore() as string, before: props.selectedBefore() as string,
after: props.selectedAfter() as string, after: props.selectedAfter() as string,
} }
@@ -122,16 +109,16 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <MonacoDiffViewer
scopeKey={props.scopeKey()} scopeKey={props.scopeKey()}
path={String(file().path || "")} path={String(file().path || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
} }
> >
@@ -154,8 +141,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div> const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> <Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -186,8 +173,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> <Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -217,19 +204,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}> <span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
<span class="file-path-text">{selected?.path || "Git Changes"}</span> <span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totalsValue.additions}</span> <span class="files-tab-stat-value">+{totals.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totalsValue.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -239,23 +226,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
class="files-header-icon-button" class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={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" }} style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </button>
<DiffToolbar <DiffToolbar
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()} wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange} onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange} onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange} onWordWrapModeChange={props.onWordWrapModeChange}
/> />
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}

View File

@@ -1,9 +1,8 @@
import { For, Show, type Accessor, type Component } from "solid-js" import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance" import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -207,25 +206,21 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
render: renderStatusSessionChanges, render: renderStatusSessionChanges,
}, },
{ {
id: "plan", id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan", labelKey: "instanceShell.rightPanel.sections.plan",
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
render: renderPlanSectionContent, render: renderPlanSectionContent,
}, },
{ {
id: "background-processes", id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
render: renderBackgroundProcesses, render: renderBackgroundProcesses,
}, },
{ {
id: "mcp", id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp", labelKey: "instanceShell.rightPanel.sections.mcp",
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -238,7 +233,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "lsp", id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp", labelKey: "instanceShell.rightPanel.sections.lsp",
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -251,7 +245,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "plugins", id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins", labelKey: "instanceShell.rightPanel.sections.plugins",
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -283,23 +276,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left"> <span>{props.t(section.labelKey)}</span>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />

View File

@@ -1,5 +1,5 @@
import { batch, createMemo, type Accessor } from "solid-js" import { batch, createMemo, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { Session } from "../../../types/session" import type { Session } from "../../../types/session"
import { import {
activeParentSessionId, activeParentSessionId,

View File

@@ -92,6 +92,7 @@ export function Markdown(props: MarkdownProps) {
const globalCache = cacheHandle.get<RenderCache>() const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && cacheMatches(globalCache)) { if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html) setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered() notifyRendered()
return return
} }
@@ -99,11 +100,14 @@ export function Markdown(props: MarkdownProps) {
const commitCacheEntry = (renderedHtml: string) => { const commitCacheEntry = (renderedHtml: string) => {
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version } const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml) setHtml(renderedHtml)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
notifyRendered() notifyRendered()
} }
if (!highlightEnabled) { if (!highlightEnabled) {
part.renderCache = undefined
try { try {
const rendered = await renderMarkdown(text, { suppressHighlight: true }) const rendered = await renderMarkdown(text, { suppressHighlight: true })
@@ -181,6 +185,7 @@ export function Markdown(props: MarkdownProps) {
if (latestRequestedText === text) { if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version } const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered) setHtml(rendered)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
notifyRendered() notifyRendered()
} }
@@ -197,15 +202,5 @@ export function Markdown(props: MarkdownProps) {
const proseClass = () => "markdown-body" const proseClass = () => "markdown-body"
return ( return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
<div
ref={containerRef}
class={proseClass()}
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
)
} }

View File

@@ -1,9 +0,0 @@
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
}

View File

@@ -0,0 +1,64 @@
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<HTMLDivElement | undefined>
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 (
<>
<Index each={props.messageIds()}>
{(messageId, index) => (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)}
</Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</>
)
}

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -12,17 +12,8 @@ import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" 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 TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -32,10 +23,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk/v2").ToolState type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running") return Boolean(state && state.status === "running")
@@ -203,13 +194,8 @@ interface MessageContentItemProps {
messageIndex: number messageIndex: number
lastAssistantIndex: () => number lastAssistantIndex: () => number
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function isSupportedPartType(part: unknown): boolean { function isSupportedPartType(part: unknown): boolean {
@@ -296,12 +282,7 @@ function MessageContentItem(props: MessageContentItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
showAgentMeta={showAgentMeta()} showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -317,41 +298,11 @@ interface ToolCallItemProps {
messageId: string messageId: string
partId: string partId: string
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = 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 record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -368,6 +319,14 @@ function ToolCallItem(props: ToolCallItemProps) {
const messageVersion = createMemo(() => record()?.revision ?? 0) const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.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 taskSessionId = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return "" if (!state) return ""
@@ -391,72 +350,38 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location) navigateToTaskSession(location)
} }
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteToolPart = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (deleteDisabled()) return
if (deletingMessage()) return
setDeletingMessage(true) setDeleting(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messageBlock.tool.deletePart.failed.title"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(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 ( return (
<Show when={toolPart()}> <Show when={toolPart()}>
{(resolvedToolPart) => ( {(resolvedToolPart) => (
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}> <>
<div class="tool-call-header-label"> <div class="tool-call-header-label">
<div class="tool-call-header-meta"> <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 class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span> <span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div> </div>
<div class="flex items-center gap-0"> <div class="flex items-center gap-2">
<Show when={taskSessionId()}> <Show when={taskSessionId()}>
<button <button
class="tool-call-header-button" class="tool-call-header-button"
@@ -470,33 +395,16 @@ function ToolCallItem(props: ToolCallItemProps) {
</button> </button>
</Show> </Show>
<Show when={props.showDeleteMessage}> <button
<button class="tool-call-header-button"
class="tool-call-header-button" type="button"
type="button" disabled={deleteDisabled()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} onClick={handleDeleteToolPart}
onClick={handleDeleteUpTo} title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} >
title={t("messageItem.actions.deleteMessagesUpTo")} <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
aria-label={t("messageItem.actions.deleteMessagesUpTo")} </button>
>
<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>
</div> </div>
@@ -510,7 +418,7 @@ function ToolCallItem(props: ToolCallItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </>
)} )}
</Show> </Show>
) )
@@ -562,13 +470,7 @@ interface MessageBlockProps {
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
} }
@@ -579,30 +481,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) 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 block = createMemo<MessageDisplayBlock | null>(() => {
const current = record() const current = record()
if (!current) return null if (!current) return null
@@ -790,13 +668,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return ( return (
<Show when={block()}> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div <div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}> <For each={resolvedBlock().items}>
{(item, index) => ( {(item) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item.type === "content"}>
<MessageContentItem <MessageContentItem
@@ -807,12 +681,7 @@ export default function MessageBlock(props: MessageBlockProps) {
startPartId={(item as ContentDisplayItem).startPartId} startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -828,13 +697,6 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} 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} onContentRendered={props.onContentRendered}
/> />
</div> </div>
@@ -847,14 +709,6 @@ export default function MessageBlock(props: MessageBlockProps) {
part={(item as StepDisplayItem).part} part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta 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>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
@@ -864,14 +718,6 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()} showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor} 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>
<Match when={item.type === "compaction"}> <Match when={item.type === "compaction"}>
@@ -882,11 +728,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0} partId={(item as CompactionDisplayItem).partId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
@@ -896,13 +738,9 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -921,14 +759,6 @@ interface StepCardProps {
showAgentMeta?: boolean showAgentMeta?: boolean
showUsage?: boolean showUsage?: boolean
borderColor?: string 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 { interface CompactionCardProps {
@@ -938,18 +768,12 @@ interface CompactionCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
showDeleteMessage?: boolean partId: string
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function CompactionCard(props: CompactionCardProps) { function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -957,98 +781,44 @@ function CompactionCard(props: CompactionCardProps) {
const containerClass = () => const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDelete = () => Boolean(props.partId) && !deleting()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDelete = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (!canDelete()) return
if (!canDeleteMessage()) return setDeleting(true)
setDeletingMessage(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(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 ( return (
<div <div
class={`delete-hover-scope ${containerClass()} relative`} class={`${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label={t("messageBlock.compaction.ariaLabel")} aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> <button
<Show when={props.showDeleteMessage}> type="button"
<button class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
type="button" disabled={!canDelete()}
class="tool-call-header-button" onClick={handleDelete}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} title={t("messagePart.actions.deleteTitle")}
onClick={handleDeleteUpTo} >
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} {deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} </button>
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"> <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" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span> <span class="message-compaction-label">{label()}</span>
</div> </div>
@@ -1058,9 +828,6 @@ function CompactionCard(props: CompactionCardProps) {
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const { t } = useI18n() 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 timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value) const date = new Date(value)
@@ -1105,42 +872,6 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) 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 renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
@@ -1171,83 +902,17 @@ function StepCard(props: StepCardProps) {
return null return null
} }
return ( return (
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}> <div class={`message-step-card message-step-finish message-step-finish-flush`} 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)} {renderUsageChips(usage)}
</div> </div>
) )
} }
return ( return (
<div class={`message-step-card message-step-start relative`}> <div class={`message-step-card message-step-start`}>
<div class="message-step-heading"> <div class="message-step-heading">
<div class="message-step-title"> <div class="message-step-title">
<div class="message-step-title-left"> <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())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show> <Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
@@ -1274,27 +939,15 @@ interface ReasoningCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded?: 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) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = 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(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1321,35 +974,6 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID 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 reasoningText = () => {
const part = props.part as any const part = props.part as any
if (!part) return "" if (!part) return ""
@@ -1390,45 +1014,30 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const hasDeleteTarget = () => Boolean(props.partId)
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDelete = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (!canDelete()) return
if (!canDeleteMessage()) return setDeleting(true)
setDeletingMessage(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(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 ( return (
<div class="delete-hover-scope message-reasoning-card"> <div class="message-reasoning-card">
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}> <div class="message-reasoning-header">
<button <button
type="button" type="button"
class="message-reasoning-toggle" class="message-reasoning-toggle"
@@ -1436,30 +1045,9 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-expanded={expanded()} aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label"> <span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}> <span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showDeleteMessage}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<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"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}> <Show when={agentIdentifier()}>
{(value) => ( {(value) => (
@@ -1473,28 +1061,10 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</span> </span>
</Show> </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> </span>
</button> </button>
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}> <div class="message-reasoning-actions">
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
@@ -1511,31 +1081,16 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</button> </button>
<Show when={props.showDeleteMessage}> <Show when={hasDeleteTarget()}>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
onClick={handleDeleteUpTo} onClick={handleDelete}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} disabled={!canDelete()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} aria-label={t("messagePart.actions.deleteTitle")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={t("messagePart.actions.deleteTitle")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
> >
<DeleteUpToIcon /> <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</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> </button>
</Show> </Show>
@@ -1543,23 +1098,6 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</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()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">

View File

@@ -1,24 +1,14 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { For, Show, createSignal } from "solid-js"
import { Portal } from "solid-js/web" import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart } from "../types/message"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" 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 { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -28,112 +18,15 @@ interface MessageItemProps {
isQueued?: boolean isQueued?: boolean
parts: ClientPart[] parts: ClientPart[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
type ImagePreviewState = {
url: string
name: string
anchor: HTMLElement
}
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const getImagePreviewPosition = () => {
const state = imagePreview()
if (!state) return null
const rect = state.anchor.getBoundingClientRect()
// Outer box: 320px image + 8px padding on each side.
const padding = 8
const maxImage = 320
const gap = 8
const chrome = padding * 2
const outerWidth = maxImage + chrome
const outerHeight = maxImage + chrome
const viewportW = window.innerWidth
const viewportH = window.innerHeight
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
const fitsAbove = rect.top >= outerHeight + gap + 8
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
return { left, top }
}
createEffect(() => {
const active = imagePreview()
if (!active) return
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
// Hide the popover to avoid showing it in the wrong place.
const hide = () => setImagePreview(null)
window.addEventListener("scroll", hide, true)
window.addEventListener("resize", hide)
onCleanup(() => {
window.removeEventListener("scroll", hide, true)
window.removeEventListener("resize", hide)
})
})
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 isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -230,11 +123,6 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
if (!url) return
setImagePreview({ anchor, url, name })
}
const errorMessage = () => { const errorMessage = () => {
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.error) return null if (!info || info.role !== "assistant" || !info.error) return null
@@ -302,30 +190,47 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const handleDeleteMessage = async () => { const deletableTextPartId = () => {
if (deletingMessage()) return const part = props.parts.find((candidate) => {
setDeletingMessage(true) 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)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.record.id) await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setPartDeleting(partId, false)
}
}
const handleDeleteUpTo = async () => {
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.record.id)
} finally {
setDeletingUpTo(false)
} }
} }
@@ -353,16 +258,8 @@ export default function MessageItem(props: MessageItemProps) {
if (!info || info.role !== "assistant") return "" if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || "" const modelID = info.modelID || ""
const providerID = info.providerID || "" const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID return 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 = () => { const agentMeta = () => {
@@ -381,68 +278,28 @@ export default function MessageItem(props: MessageItemProps) {
return ( return (
<div <div class={containerClass()}>
class={containerClass()}
data-view="message-item"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}> <header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}> <div class="message-item-header-row message-item-header-row--top">
<div class="message-header-left"> <div class="message-speaker">
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}> <span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
<Show when={props.showDeleteMessage}> {speakerLabel()}
<input </span>
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>
<div class="message-item-actions" ref={(el) => (actionsEl = el)}> <div class="message-item-actions">
<Show when={isUser()}> <Show when={isUser()}>
<div class="message-action-group"> <div class="message-action-group">
<button <Show when={props.onRevert}>
class="message-action-button" <button
onClick={handleCopy} class="message-action-button"
title={copyLabel()} onClick={handleRevert}
aria-label={copyLabel()} title={t("messageItem.actions.revert")}
> aria-label={t("messageItem.actions.revert")}
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> >
</button> <Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
@@ -453,43 +310,14 @@ export default function MessageItem(props: MessageItemProps) {
<Split class="w-3.5 h-3.5" aria-hidden="true" /> <Split class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<button
<Show when={props.onRevert}> class="message-action-button"
<button onClick={handleCopy}
class="message-action-button" title={copyLabel()}
onClick={handleRevert} aria-label={copyLabel()}
title={t("messageItem.actions.revertTitle")} >
aria-label={t("messageItem.actions.revertTitle")} <Copy class="w-3.5 h-3.5" aria-hidden="true" />
> </button>
<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> </div>
</Show> </Show>
<Show when={!isUser()}> <Show when={!isUser()}>
@@ -503,30 +331,18 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={props.showDeleteMessage}> <Show when={deletableTextPartId()}>
<button {(partId) => (
class="message-action-button" <button
onClick={() => void handleDeleteUpTo()} class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} onClick={() => void handleDeletePart(partId())}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })} disabled={isDeletingPart(partId())}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
title={t("messageItem.actions.deleteMessagesUpTo")} aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")} >
> <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
<DeleteUpToIcon /> </button>
</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> </Show>
</div> </div>
</Show> </Show>
@@ -534,10 +350,12 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</div> </div>
<Show when={metaText() && !showMetaInline()}> <Show when={agentMeta()}>
<div class="message-item-header-row message-item-header-row--meta"> {(meta) => (
<span class="message-agent-meta-block">{metaText()}</span> <div class="message-item-header-row message-item-header-row--bottom">
</div> <span class="message-agent-meta">{meta()}</span>
</div>
)}
</Show> </Show>
</header> </header>
@@ -560,20 +378,16 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<For each={messageParts()}> <For each={messageParts()}>
{(part) => { {(part) => (
return ( <MessagePart
<div class="message-part-shell"> part={part}
<MessagePart messageType={props.record.role}
part={part} instanceId={props.instanceId}
messageType={props.record.role} sessionId={props.sessionId}
instanceId={props.instanceId} primaryUserTextPartId={primaryUserTextPartId()}
sessionId={props.sessionId} onRendered={props.onContentRendered}
primaryUserTextPartId={primaryUserTextPartId()} />
onRendered={props.onContentRendered} )}
/>
</div>
)
}}
</For> </For>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
@@ -583,16 +397,7 @@ export default function MessageItem(props: MessageItemProps) {
const name = getAttachmentName(attachment) const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment) const isImage = isImageAttachment(attachment)
return ( return (
<div <div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={name}
onMouseEnter={(e) => {
if (!isImage) return
const el = e.currentTarget as HTMLElement
showImagePreview(el, attachment.url || "", name)
}}
onMouseLeave={() => setImagePreview(null)}
>
<Show when={isImage} fallback={ <Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -620,6 +425,24 @@ export default function MessageItem(props: MessageItemProps) {
</svg> </svg>
</button> </button>
</Show> </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} />
</div>
</Show>
</div> </div>
) )
}} }}
@@ -627,31 +450,6 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")} <span class="generating-spinner"></span> {t("messageItem.status.sending")}

View File

@@ -131,12 +131,7 @@ export default function MessagePart(props: MessagePartProps) {
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div <div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
data-role={textContainerRole()}
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}

View File

@@ -1,18 +1,12 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
interface MessagePreviewProps { interface MessagePreviewProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
store: () => InstanceMessageStore 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) => { const MessagePreview: Component<MessagePreviewProps> = (props) => {
@@ -30,11 +24,6 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
showThinking={() => false} showThinking={() => false}
thinkingDefaultExpanded={() => false} thinkingDefaultExpanded={() => false}
showUsageMetrics={() => false} showUsageMetrics={() => false}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</div> </div>
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
import { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -21,38 +19,18 @@ export interface TimelineSegment {
shortLabel?: string shortLabel?: string
variant?: "auto" | "manual" variant?: "auto" | "manual"
toolPartIds?: string[] toolPartIds?: string[]
partIds?: string[]
partId?: string
totalChars: number
} }
interface MessageTimelineProps { interface MessageTimelineProps {
segments: TimelineSegment[] segments: TimelineSegment[]
onSegmentClick?: (segment: TimelineSegment) => void onSegmentClick?: (segment: TimelineSegment) => void
onToggleSelection?: (id: string) => void activeMessageId?: string | null
onLongPressSelection?: (segment: TimelineSegment) => void
onSelectRange?: (id: string) => void
onClearSelection?: () => void
selectedIds?: Accessor<Set<string>>
expandedMessageIds?: Accessor<Set<string>>
// Optional: restrict histogram/xray overlay to only show for these message ids.
// Used to hide ribs for messages before the last compaction.
deletableMessageIds?: Accessor<Set<string>>
activeSegmentId?: string | null
instanceId: string instanceId: string
sessionId: string sessionId: string
showToolSegments?: boolean 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 const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -60,8 +38,10 @@ interface PendingSegment {
type: TimelineSegmentType type: TimelineSegmentType
texts: string[] texts: string[]
reasoningTexts: string[] reasoningTexts: string[]
partIds: string[] toolTitles: string[]
totalChars: number toolTypeLabels: string[]
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean hasPrimaryText: boolean
} }
@@ -191,13 +171,18 @@ export function buildTimelineSegments(
pending = null pending = null
return return
} }
const label = segmentLabel(pending.type) const isToolSegment = pending.type === "tool"
const shortLabel = undefined const label = isToolSegment
const tooltip = formatTextsTooltip( ? pending.toolTypeLabels[0] || segmentLabel("tool")
[...pending.texts, ...pending.reasoningTexts], : segmentLabel(pending.type)
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"), 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"),
)
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -205,24 +190,16 @@ export function buildTimelineSegments(
label, label,
tooltip, tooltip,
shortLabel, shortLabel,
partIds: pending.partIds, toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
totalChars: pending.totalChars,
}) })
segmentIndex += 1 segmentIndex += 1
pending = null pending = null
} }
const ensureSegment = (type: TimelineSegmentType): PendingSegment => { const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) { if (!pending || pending.type !== type) {
flushPending() flushPending()
pending = { pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
type,
texts: [],
reasoningTexts: [],
partIds: [],
totalChars: 0,
hasPrimaryText: type !== "assistant",
}
} }
return pending! return pending!
} }
@@ -234,21 +211,14 @@ export function buildTimelineSegments(
if (!part || typeof part !== "object") continue if (!part || typeof part !== "object") continue
if (part.type === "tool") { if (part.type === "tool") {
flushPending() const target = ensureSegment("tool")
const toolPart = part as ToolCallPart const toolPart = part as ToolCallPart
const partId = typeof toolPart.id === "string" ? toolPart.id : "" target.toolTitles.push(getToolTitle(toolPart, t))
const title = getToolTitle(toolPart, t) target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
result.push({ target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
id: `${record.id}:${segmentIndex}`, if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
messageId: record.id, target.toolPartIds.push(toolPart.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,
totalChars: getPartCharCount(part),
})
segmentIndex += 1
continue continue
} }
@@ -258,18 +228,13 @@ export function buildTimelineSegments(
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.reasoningTexts.push(text) target.reasoningTexts.push(text)
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
target.totalChars += getPartCharCount(part)
} }
continue continue
} }
if (part.type === "compaction") { if (part.type === "compaction") {
flushPending() flushPending()
const isAuto = Boolean((part as any)?.auto) const isAuto = Boolean((part as any)?.auto)
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -277,8 +242,6 @@ export function buildTimelineSegments(
label: segmentLabel("compaction"), label: segmentLabel("compaction"),
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
partId,
totalChars: 0,
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -287,23 +250,19 @@ export function buildTimelineSegments(
if (part.type === "step-start" || part.type === "step-finish") { if (part.type === "step-start" || part.type === "step-finish") {
continue continue
} }
const text = collectTextFromPart(part, t) const text = collectTextFromPart(part, t)
if (text.trim().length === 0) continue if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.texts.push(text) target.texts.push(text)
target.hasPrimaryText = true target.hasPrimaryText = true
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
target.totalChars += getPartCharCount(part)
} }
} }
flushPending() flushPending()
return result return result
} }
@@ -319,14 +278,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
let hoverTimer: number | null = null let hoverTimer: number | null = null
let closeTimer: number | null = null let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
const isHistogramEligible = (segment: TimelineSegment): boolean => {
const allowed = props.deletableMessageIds?.()
if (!allowed) return true
return allowed.has(segment.messageId)
}
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
buttonRefs.set(segmentId, element) buttonRefs.set(segmentId, element)
@@ -334,7 +286,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
buttonRefs.delete(segmentId) buttonRefs.delete(segmentId)
} }
} }
const clearHoverTimer = () => { const clearHoverTimer = () => {
if (hoverTimer !== null && typeof window !== "undefined") { if (hoverTimer !== null && typeof window !== "undefined") {
window.clearTimeout(hoverTimer) window.clearTimeout(hoverTimer)
@@ -360,11 +312,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setHoverAnchorRect(null) setHoverAnchorRect(null)
}, 160) }, 160)
} }
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => { const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
// Suppress previews during long-press selection gestures.
if (longPressTimer !== null) return
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
clearCloseTimer() clearCloseTimer()
@@ -379,7 +328,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const handleMouseLeave = () => { const handleMouseLeave = () => {
scheduleClose() scheduleClose()
} }
createEffect(() => { createEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const anchor = hoverAnchorRect() const anchor = hoverAnchorRect()
@@ -401,235 +350,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
clearCloseTimer() clearCloseTimer()
}) })
// --- Selection & histogram rib state ---
const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0)
// Segments eligible for xray ribs. We intentionally exclude messages before
// the last compaction (when provided by the parent) to avoid misleading token
// weights for content that's no longer in context.
const xraySegments = createMemo(() => {
if (!isSelectionActive()) return [] as TimelineSegment[]
return props.segments.filter((segment) => isHistogramEligible(segment))
})
// Stable layout offsets per badge (relative to scroll content), recomputed only
// on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined
// Full layout recomputation: reads every badge's getBoundingClientRect once,
// then stores offsets relative to the scroll content so they survive scrolling.
const computeBadgeLayout = () => {
if (!isSelectionActive() || !scrollContainerRef) return
const containerRect = scrollContainerRef.getBoundingClientRect()
const scrollTop = scrollContainerRef.scrollTop
const offsets: Record<string, { layoutTop: number; height: number }> = {}
for (const [id, element] of buttonRefs.entries()) {
if (!element) continue
const rect = element.getBoundingClientRect()
// Store position relative to scroll content (survives scrolling).
offsets[id] = {
layoutTop: rect.top - containerRect.top + scrollTop,
height: rect.height,
}
}
setBadgeOffsets(offsets)
if (xrayOverlayRef) {
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
}
if (typeof window !== "undefined") {
setWindowWidth(window.innerWidth)
}
}
const handleScroll = () => {
if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
}
createEffect(() => { createEffect(() => {
if (isSelectionActive()) { const activeId = props.activeMessageId
computeBadgeLayout()
if (typeof window !== "undefined") {
// Deferred pass: tool segments become visible when selection activates,
// but they may need a layout pass before getBoundingClientRect is accurate.
requestAnimationFrame(computeBadgeLayout)
window.addEventListener("resize", computeBadgeLayout)
onCleanup(() => {
window.removeEventListener("resize", computeBadgeLayout)
})
}
}
})
// Re-compute badge layout after expansion changes (tools become visible in DOM)
createEffect(() => {
props.expandedMessageIds?.()
if (isSelectionActive()) {
requestAnimationFrame(computeBadgeLayout)
}
})
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for
// tool parts whose output arrived after the timeline segment was first built.
const liveSegmentChars = createMemo(() => {
if (!isSelectionActive()) return {} as Record<string, number>
const result: Record<string, number> = {}
const resolvedStore = store()
// Compute live char counts by reading only the parts that the segment
// references (partIds/toolPartIds). This stays accurate for streamed tool
// outputs without scanning every part in the message.
for (const segment of xraySegments()) {
const record = resolvedStore.getMessage(segment.messageId)
if (!record) {
result[segment.id] = segment.totalChars
continue
}
const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])]
let chars = 0
for (const partId of ids) {
const part = record.parts?.[partId]?.data
if (!part) continue
chars += getPartCharCount(part)
}
result[segment.id] = chars > 0 ? chars : segment.totalChars
}
return result
})
// Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup.
// Avoids the previous O(n²) pattern of iterating all segments inside each <For> item.
const aggregateTokensByMessageId = createMemo(() => {
const chars = liveSegmentChars()
const result: Record<string, number> = {}
for (const s of xraySegments()) {
result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars)
}
for (const id of Object.keys(result)) {
result[id] = Math.max(Math.round(result[id] / 4), 1)
}
return result
})
const getSegmentTokens = (segment: TimelineSegment): number => {
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
// When tools are hidden (not expanded, not in selection mode), assistant/user
// bars show aggregate tokens for the whole message. When tools are visible
// (expanded or selection mode active), each segment shows its own tokens to
// avoid double-counting.
if (!isExpanded && !isSelectionActive() && (segment.type === "assistant" || segment.type === "user")) {
return aggregateTokensByMessageId()[segment.messageId] ?? 1
}
const chars = liveSegmentChars()[segment.id] ?? segment.totalChars
return Math.max(Math.round(chars / 4), 1)
}
const getMessageAggregateTokens = (messageId: string): number => {
return aggregateTokensByMessageId()[messageId] ?? 1
}
const formatTokenLabel = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return String(tokens)
}
const maxTokens = createMemo(() => {
let max = 0
for (const s of xraySegments()) {
const tokens = getSegmentTokens(s)
if (tokens > max) max = tokens
}
return Math.max(max, 1)
})
// --- Long-press for mobile selection ---
let longPressTimer: number | null = null
let wasLongPress = false
let pressStartPos = { x: 0, y: 0 }
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
if (event.button !== 0) return
wasLongPress = false
pressStartPos = { x: event.clientX, y: event.clientY }
clearHoverTimer()
clearCloseTimer()
if (longPressTimer !== null && typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
}
if (typeof window !== "undefined") {
longPressTimer = window.setTimeout(() => {
longPressTimer = null
wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
if (props.onLongPressSelection) {
props.onLongPressSelection(segment)
} else {
props.onToggleSelection?.(segment.id)
}
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}, LONG_PRESS_MS)
}
}
const handlePointerUp = () => {
if (longPressTimer !== null && typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
longPressTimer = null
}
}
const handlePointerMove = (event: PointerEvent) => {
if (longPressTimer !== null) {
const dist = Math.sqrt(
Math.pow(event.clientX - pressStartPos.x, 2) +
Math.pow(event.clientY - pressStartPos.y, 2),
)
if (dist > JITTER_THRESHOLD) {
if (typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
}
longPressTimer = null
}
}
}
const handleContextMenu = (event: MouseEvent) => {
if (wasLongPress) {
event.preventDefault()
}
}
createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const element = buttonRefs.get(activeId) const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
if (!targetSegment) return
const element = buttonRefs.get(targetSegment.id)
if (!element) return if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
@@ -639,7 +366,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
window.clearTimeout(timer) window.clearTimeout(timer)
} }
}) })
})) })
createEffect(() => { createEffect(() => {
const element = tooltipElement() const element = tooltipElement()
@@ -656,265 +383,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
const previewData = createMemo(() => { const previewData = createMemo(() => {
const segment = hoveredSegment() const segment = hoveredSegment()
if (!segment) return null if (!segment) return null
const record = store().getMessage(segment.messageId) const record = store().getMessage(segment.messageId)
if (!record) return null if (!record) return null
return { messageId: segment.messageId } return { messageId: segment.messageId }
}) })
// Pre-computed set of messageIds that have at least one tool segment.
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
const messagesWithTools = createMemo(() => {
const set = new Set<string>()
for (const s of props.segments) {
if (s.type === "tool") set.add(s.messageId)
}
return set
})
// Pre-computed index map for session message ordering.
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
const messageIdToSessionIndex = createMemo(() => {
const ids = store().getSessionMessageIds(props.sessionId)
const map = new Map<string, number>()
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
return map
})
return ( return (
<div class="message-timeline-container"> <div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
<div <For each={props.segments}>
ref={scrollContainerRef} {(segment) => {
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} onCleanup(() => buttonRefs.delete(segment.id))
role="navigation" const isActive = () => props.activeMessageId === segment.messageId
aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll}
>
<For each={props.segments}>
{(segment, segIndex) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id)
const isDeleteHovered = () => { const hasActivePermission = () => {
const hover = deleteHover() as DeleteHoverState if (segment.type !== "tool") return false
if (hover.kind === "message") { const partIds = segment.toolPartIds ?? []
return hover.messageId === segment.messageId if (partIds.length === 0) return false
} for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (hover.kind === "deleteUpTo") { if (permissionState?.active) return true
const indexMap = messageIdToSessionIndex()
const targetIndex = indexMap.get(hover.messageId)
if (targetIndex === undefined) return false
const segmentIndex = indexMap.get(segment.messageId)
if (segmentIndex === undefined) return false
return segmentIndex >= targetIndex
}
return false
} }
return false
}
const isDeleteSelected = () => { const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => { const shortLabelContent = () => {
if (segment.type !== "tool") return false if (segment.type === "tool") {
const partIds = segment.toolPartIds ?? [] if (hasActivePermission()) {
if (partIds.length === 0) return false return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency).
const groupRole = (): "child" | "parent" | "none" => {
if (segment.type === "tool") return "child"
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none"
}
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
}
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
} }
if (segment.type === "compaction") { return segment.shortLabel ?? getToolIcon("tool")
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
data-variant={segment.variant} 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" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} 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" : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} onClick={() => props.onSegmentClick?.(segment)}
aria-hidden={isHidden() ? "true" : undefined} onMouseEnter={(event) => handleMouseEnter(segment, event)}
onClick={(event) => { onMouseLeave={handleMouseLeave}
if (wasLongPress) { >
wasLongPress = false <span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
return <span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
} </button>
)
// Capture scroll anchor before selection changes may toggle }}
// tool segment visibility, which shifts timeline layout. </For>
const btn = buttonRefs.get(segment.id) <Show when={previewData()}>
let anchorOffset: number | null = null {(data) => {
if (btn && scrollContainerRef) { onCleanup(() => setTooltipElement(null))
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop return (
} <div
ref={(element) => setTooltipElement(element)}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0 class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
if (event.shiftKey) { onMouseEnter={() => clearCloseTimer()}
props.onSelectRange?.(segment.id) onMouseLeave={() => scheduleClose()}
} else if (event.ctrlKey || event.metaKey) { >
props.onToggleSelection?.(segment.id) <MessagePreview
} else if (isMultiSelectActive) { messageId={data().messageId}
// In selection mode, plain click scrolls to the message instanceId={props.instanceId}
// instead of clearing. Selection is cleared by clicking sessionId={props.sessionId}
// anywhere inside the chat container or pressing Esc. store={store}
props.onSegmentClick?.(segment) />
} else { </div>
props.onSegmentClick?.(segment) )
} }}
// Restore scroll anchor: keep the clicked badge at the same
// visual position after hidden tools appear or disappear.
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
)
}}
</For>
<Show when={previewData()}>
{(data) => {
onCleanup(() => setTooltipElement(null))
return (
<div
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()}
onMouseLeave={() => scheduleClose()}
>
<MessagePreview
messageId={data().messageId}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
/>
</div>
)
}}
</Show>
</div>
<Show when={isSelectionActive()}>
<div
ref={(el) => {
xrayOverlayRef = el
if (xrayOverlayRef && scrollContainerRef) {
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
}
}}
class="message-timeline-xray-overlay"
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
>
<div class="message-timeline-xray-overlay-inner">
<For each={xraySegments()}>
{(segment) => {
const pos = () => {
const offset = badgeOffsets()[segment.id]
if (!offset) return null
return { top: offset.layoutTop + offset.height / 2 }
}
const tokens = () => getSegmentTokens(segment)
const relativeWeight = () => tokens() / maxTokens()
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
const isParent = segment.type === "assistant" || segment.type === "user"
const displayTokens = () =>
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
return (
<Show when={pos()}>
<div
class="message-timeline-xray-rib"
style={{
top: `${pos()!.top}px`,
left: "var(--xray-overhang)",
}}
>
<span class="message-timeline-xray-token-label">
{formatTokenLabel(displayTokens())}
</span>
<div
class="message-timeline-relative-bar"
style={{ "--segment-weight": relativeWeight() }}
/>
<div
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
style={{ "--segment-weight": absoluteWeight() }}
/>
</div>
</Show>
)
}}
</For>
</div>
</div>
</Show> </Show>
</div> </div>
) )
} }
export default MessageTimeline export default MessageTimeline

View File

@@ -351,9 +351,7 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n") const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return if (!blockquote) return
// End the blockquote with a blank line so the user's next line insertBlockContent(`${blockquote}\n`)
// doesn't get parsed as a lazy continuation of the quote.
insertBlockContent(`${blockquote}\n\n`)
} }
function insertCodeSelection(rawText: string) { function insertCodeSelection(rawText: string) {

View File

@@ -10,7 +10,6 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
@@ -56,22 +55,12 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
let promptInputApi: PromptInputApi | null = null let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let scrollToBottomHandle: (() => void) | undefined let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined let rootRef: HTMLDivElement | undefined
function shouldScrollToBottomOnActivate() {
const current = session()
if (!current) return true
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
return !snapshot || snapshot.atBottom
}
function scheduleScrollToBottom() { function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return if (!scrollToBottomHandle) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -80,7 +69,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
if (!props.isActive) return if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom() scheduleScrollToBottom()
}) })
@@ -237,35 +225,6 @@ 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) { async function handleFork(messageId?: string) {
if (!messageId) { if (!messageId) {
log.warn("Fork requires a user message id") log.warn("Fork requires a user message id")
@@ -324,17 +283,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
<MessageSection <MessageSection
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={activeSession.id} sessionId={activeSession.id}
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onFork={handleFork}
onFork={handleFork} isActive={props.isActive}
isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { if (props.isActive) {
if (shouldScrollToBottomOnActivate()) { scheduleScrollToBottom()
scheduleScrollToBottom()
}
} }
}} }}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
import { tGlobal } from "../../lib/i18n" import { tGlobal } from "../../lib/i18n"

View File

@@ -1,5 +1,5 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { TextPart } from "../../types/message" import type { TextPart } from "../../types/message"
import { Markdown } from "../markdown" import { Markdown } from "../markdown"
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types" import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"

View File

@@ -1,5 +1,5 @@
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"

View File

@@ -1,5 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
@@ -178,116 +178,28 @@ export const taskRenderer: ToolRenderer = {
void loadMessages(instanceId, id) void loadMessages(instanceId, id)
}) })
const [childToolKeys, setChildToolKeys] = createSignal<string[]>([]) const childToolKeys = createMemo(() => {
let indexedSessionId = ""
let indexedMessageCount = 0
let indexedMessageTail = ""
const indexedPartCounts = new Map<string, number>()
function resetChildToolIndex(nextSessionId: string) {
indexedSessionId = nextSessionId
indexedMessageCount = 0
indexedMessageTail = ""
indexedPartCounts.clear()
setChildToolKeys([])
}
function scanMessageToolParts(messageId: string, startIndex: number) {
const record = store.getMessage(messageId)
if (!record) return [] as string[]
const partIds = record.partIds
const keys: string[] = []
for (let idx = startIndex; idx < partIds.length; idx += 1) {
const partId = partIds[idx]
const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.push(`${messageId}::${partId}`)
}
indexedPartCounts.set(messageId, partIds.length)
return keys
}
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
indexedSessionId = sessionId
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
indexedPartCounts.clear()
const nextKeys: string[] = []
for (const messageId of messageIds) {
nextKeys.push(...scanMessageToolParts(messageId, 0))
}
setChildToolKeys(nextKeys)
}
createEffect(() => {
const id = childSessionId() const id = childSessionId()
const loaded = childSessionLoaded() if (!id) return [] as string[]
if (!childSessionLoaded()) return [] as string[]
if (!id || !loaded) { // React to session changes, but do the scan untracked to avoid
if (indexedSessionId) { // subscribing to every message/part node in the store.
resetChildToolIndex("")
}
return
}
// We use the session revision as the reactive change point, but avoid
// rescanning the entire session on every update.
store.getSessionRevision(id) store.getSessionRevision(id)
return untrack(() => {
untrack(() => {
const messageIds = store.getSessionMessageIds(id) const messageIds = store.getSessionMessageIds(id)
const keys: string[] = []
if (!indexedSessionId || indexedSessionId !== id) { for (const messageId of messageIds) {
fullRescanChildTools(id, messageIds)
return
}
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
if (messageIds.length < indexedMessageCount) {
fullRescanChildTools(id, messageIds)
return
}
if (indexedMessageCount > 0) {
const expectedTailIndex = indexedMessageCount - 1
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
fullRescanChildTools(id, messageIds)
return
}
}
const appendedKeys: string[] = []
// Scan any new messages appended since last index.
for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) {
const messageId = messageIds[idx]
appendedKeys.push(...scanMessageToolParts(messageId, 0))
}
// Scan a small window of recent messages for newly appended parts.
// Deltas typically affect the most recent tool call, so this avoids
// iterating every message on every revision.
const existingCount = Math.min(indexedMessageCount, messageIds.length)
const windowStart = Math.max(0, existingCount - 3)
for (let idx = windowStart; idx < existingCount; idx += 1) {
const messageId = messageIds[idx]
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
const record = store.getMessage(messageId) const record = store.getMessage(messageId)
const nextPartCount = record?.partIds.length ?? 0 if (!record) continue
if (nextPartCount > previousPartCount) { for (const partId of record.partIds) {
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount)) const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.push(`${messageId}::${partId}`)
} }
} }
return keys
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
if (appendedKeys.length > 0) {
setChildToolKeys((prev) => [...prev, ...appendedKeys])
}
}) })
}) })
const promptContent = createMemo(() => { const promptContent = createMemo(() => {
@@ -375,9 +287,7 @@ export const taskRenderer: ToolRenderer = {
content: promptContent()!, content: promptContent()!,
cacheKey: "task:prompt", cacheKey: "task:prompt",
disableScrollTracking: true, disableScrollTracking: true,
// Always use the normal markdown render path for prompt (even while running) disableHighlight: true,
// so the prompt doesn't visually change between running/completed states.
disableHighlight: false,
})} })}
</div> </div>
</section> </section>
@@ -442,7 +352,7 @@ export const taskRenderer: ToolRenderer = {
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
} }
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={childToolKeys()}> <For each={childToolKeys()}>
{(key) => ( {(key) => (
<Show when={renderToolCall}> <Show when={renderToolCall}>

View File

@@ -1,5 +1,5 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils" import { readToolStatePayload } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n" import { useI18n, tGlobal } from "../../../lib/i18n"

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types" import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils" import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en" import { enMessages } from "../../lib/i18n/messages/en"

View File

@@ -1,5 +1,5 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
export type ToolCallPart = Extract<ClientPart, { type: "tool" }> export type ToolCallPart = Extract<ClientPart, { type: "tool" }>

View File

@@ -1,15 +1,15 @@
import { isRenderableDiffText } from "../../lib/diff-utils" import { isRenderableDiffText } from "../../lib/diff-utils"
import { getLanguageFromPath } from "../../lib/markdown" import { getLanguageFromPath } from "../../lib/markdown"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { DiffPayload } from "./types" import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { tGlobal } from "../../lib/i18n" import { tGlobal } from "../../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
export type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
export const diffCapableTools = new Set(["edit", "patch"]) export const diffCapableTools = new Set(["edit", "patch"])

View File

@@ -1,933 +0,0 @@
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
import VirtualItem from "./virtual-item"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
export interface VirtualFollowListApi {
scrollToTop: (opts?: { immediate?: boolean }) => void
scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean }) => void
scrollToKey: (
key: string,
opts?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition; setAutoScroll?: boolean },
) => void
notifyContentRendered: () => void
setAutoScroll: (enabled: boolean) => void
getAutoScroll: () => boolean
getScrollElement: () => HTMLDivElement | undefined
getShellElement: () => HTMLDivElement | undefined
}
export interface VirtualFollowListState {
autoScroll: Accessor<boolean>
showScrollTopButton: Accessor<boolean>
showScrollBottomButton: Accessor<boolean>
scrollButtonsCount: Accessor<number>
activeKey: Accessor<string | null>
}
export interface VirtualFollowListProps<T> {
items: Accessor<T[]>
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => JSX.Element
/**
* Optional stable DOM id for the item wrapper.
* Defaults to the key itself.
*/
getAnchorId?: (key: string) => string
/**
* Decode an item key from an observed wrapper element id.
* Defaults to identity.
*/
getKeyFromAnchorId?: (anchorId: string) => string
overscanPx?: number
scrollSentinelMarginPx?: number
virtualizationEnabled?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
loading?: Accessor<boolean>
isActive?: Accessor<boolean>
/**
* When switching back to an inactive (cached) pane, the list historically
* re-pinned to the bottom if autoScroll was enabled.
*
* Disable this to preserve the existing scroll position across pane switches.
*/
scrollToBottomOnActivate?: Accessor<boolean>
/**
* Controls whether the list should scroll to bottom the first time items
* appear (default behavior for chat streams).
*
* Set to false when an outer component restores scroll from a cache.
*/
initialScrollToBottom?: Accessor<boolean>
/**
* Initial value for the internal autoScroll signal.
* Useful when restoring scroll state (e.g. start in non-follow mode).
*/
initialAutoScroll?: Accessor<boolean>
/**
* When this value changes, the list resets internal follow/anchor state.
* Useful when reusing the same list instance across different datasets.
*/
resetKey?: Accessor<string | number>
/**
* If this value changes and autoScroll is enabled, the list will
* anchor-scroll to the bottom (unless suppressed).
*/
followToken?: Accessor<string | number>
/**
* Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list.
*/
renderBeforeItems?: Accessor<JSX.Element>
/**
* Render content inside the shell, above timeline/sidebar layers.
* (Quote popovers, etc.)
*/
renderOverlay?: Accessor<JSX.Element>
/**
* Provide localized labels for built-in controls.
*/
scrollToTopAriaLabel?: Accessor<string>
scrollToBottomAriaLabel?: Accessor<string>
/**
* Receive element refs for external logic (selection, geometry, etc.)
*/
onScrollElementChange?: (element: HTMLDivElement | undefined) => void
onShellElementChange?: (element: HTMLDivElement | undefined) => void
/**
* Callbacks for consumers.
*/
onScroll?: () => void
onMouseUp?: (event: MouseEvent) => void
onClick?: (event: MouseEvent) => void
onActiveKeyChange?: (key: string | null) => void
registerApi?: (api: VirtualFollowListApi) => void
registerState?: (state: VirtualFollowListState) => void
renderControls?: (state: VirtualFollowListState, api: VirtualFollowListApi) => JSX.Element
}
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key)
const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const isActive = () => (props.isActive ? props.isActive() : true)
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const isLoading = () => Boolean(props.loading?.())
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [anchorLock, setAnchorLock] = createSignal<{ key: string; block: ScrollLogicalPosition } | null>(null)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
let containerRef: HTMLDivElement | undefined
let shellRef: HTMLDivElement | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let pendingAnchorCorrectionFrame: number | null = null
let pendingScrollCompensationScheduled = false
let pendingScrollCompensations = new Map<string, number>()
let scrollCompensationGen = 0
let pendingActiveScroll = false
let suppressAutoScrollOnce = false
let pendingInitialScroll = true
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let lastKnownScrollTop = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
const state: VirtualFollowListState = {
autoScroll,
showScrollTopButton,
showScrollBottomButton,
scrollButtonsCount,
activeKey,
}
function markUserScrollIntent(direction?: "up" | "down" | null) {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
if (direction) {
lastUserScrollIntentDirection = direction
}
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
if (!element) return
const handleWheelIntent = (event: WheelEvent) => {
const dir: "up" | "down" | null = event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null
markUserScrollIntent(dir)
}
const handlePointerIntent = () => markUserScrollIntent(null)
const handleKeyIntent = (event: KeyboardEvent) => {
if (!SCROLL_INTENT_KEYS.has(event.key)) return
const key = event.key
const dir: "up" | "down" | null =
key === "ArrowUp" || key === "PageUp" || key === "Home"
? "up"
: key === "ArrowDown" || key === "PageDown" || key === "End"
? "down"
: key === " " || key === "Spacebar"
? event.shiftKey
? "up"
: "down"
: null
markUserScrollIntent(dir)
}
element.addEventListener("wheel", handleWheelIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handleWheelIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function updateScrollIndicatorsFromVisibility() {
const hasItems = props.items().length > 0
const bottomVisible = bottomSentinelVisible()
const topVisible = topSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
function clearScrollToBottomFrames() {
if (scrollToBottomFrame !== null) {
cancelAnimationFrame(scrollToBottomFrame)
scrollToBottomFrame = null
}
if (scrollToBottomDelayedFrame !== null) {
cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
}
}
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return
if (anchorLock()) {
clearAnchorLock()
}
const sentinel = bottomSentinel()
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
setAutoScroll(true)
}
function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!isActive()) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
if (anchorLock()) {
clearAnchorLock()
}
setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
})
}
function clearAnchorLock() {
setAnchorLock(null)
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
}
function computeDesiredOffset(block: ScrollLogicalPosition, container: HTMLElement, anchorRect: DOMRect) {
if (block === "end") {
return Math.max(0, container.clientHeight - anchorRect.height)
}
if (block === "center") {
return Math.max(0, container.clientHeight / 2 - anchorRect.height / 2)
}
// Default to start.
return 0
}
function applyAnchorCorrection() {
const lock = anchorLock()
if (!lock) return
if (autoScroll()) return
if (!containerRef) return
if (typeof document === "undefined") return
const anchorId = getAnchorId(lock.key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const anchorRect = anchor.getBoundingClientRect()
const currentOffset = anchorRect.top - containerRect.top
const desiredOffset = computeDesiredOffset(lock.block, containerRef, anchorRect)
const delta = currentOffset - desiredOffset
if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) {
return
}
const nextTop = containerRef.scrollTop + delta
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(maxScrollTop, Math.max(0, nextTop))
}
function scheduleAnchorCorrection() {
if (pendingAnchorCorrectionFrame !== null) return
pendingAnchorCorrectionFrame = requestAnimationFrame(() => {
pendingAnchorCorrectionFrame = null
applyAnchorCorrection()
})
}
function handleContentRendered() {
scheduleAnchorScroll()
}
function handleScroll() {
if (!containerRef) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const previousScrollTop = lastKnownScrollTop
const currentScrollTop = containerRef.scrollTop
const deltaScrollTop = currentScrollTop - previousScrollTop
if (currentScrollTop !== lastKnownScrollTop) {
lastKnownScrollTop = currentScrollTop
}
const atBottom = bottomSentinelVisible()
const beforeAutoScroll = autoScroll()
const inferredDirection: "up" | "down" | null =
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
// If the user scrolls manually, exit key-anchored mode.
if (isUserScroll && anchorLock()) {
clearAnchorLock()
}
if (isUserScroll) {
// If the user is actively scrolling upward, exit follow-to-bottom mode
// immediately. The bottom sentinel can remain "visible" for a short
// distance due to its observer margin, which otherwise keeps autoScroll
// enabled and makes the list feel stuck.
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
setAutoScroll(false)
}
// Do not re-enable follow mode while the user's current scroll intent
// is upward. This prevents transient anchor/pin scrolls from pulling
// the list back into autoScroll(true).
if (inferredDirection !== "up") {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
} else if (!atBottom && autoScroll()) {
// If the user is scrolling up and we are no longer at the bottom,
// ensure follow mode is disabled.
setAutoScroll(false)
}
}
props.onScroll?.()
})
}
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
setScrollElement(containerRef)
props.onScrollElementChange?.(containerRef)
attachScrollIntentListeners(containerRef)
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
if (!containerRef) {
return
}
resolvePendingActiveScroll()
}
function scheduleScrollCompensation(key: string, delta: number) {
if (!containerRef) return
if (!delta || !Number.isFinite(delta)) return
if (typeof document === "undefined") return
// Only compensate while the user scrolls upward (testing default).
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") return
if (autoScroll() || anchorLock()) return
const anchorId = getAnchorId(key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const rect = anchor.getBoundingClientRect()
// Determine whether the item was fully above the viewport *before* the
// height delta applied. Items can expand downward into the viewport; in that
// case we still need to compensate to keep existing visible content stable.
const bottomAfter = rect.bottom
const bottomBefore = bottomAfter - delta
const wasAboveViewport = bottomBefore < containerRect.top
if (!wasAboveViewport) {
return
}
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
pendingScrollCompensations.set(key, next)
if (pendingScrollCompensationScheduled) return
pendingScrollCompensationScheduled = true
const gen = scrollCompensationGen
// Flush in a microtask so compensation lands before the next paint.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingScrollCompensationScheduled = false
if (!containerRef) return
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") {
pendingScrollCompensations = new Map()
return
}
if (autoScroll() || anchorLock()) {
pendingScrollCompensations = new Map()
return
}
let applied = 0
let count = 0
for (const pendingDelta of pendingScrollCompensations.values()) {
if (!pendingDelta) continue
applied += pendingDelta
count += 1
}
pendingScrollCompensations = new Map()
if (!applied) return
const before = containerRef.scrollTop
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
const nextTop = Math.min(maxScrollTop, Math.max(0, before + applied))
if (nextTop !== before) {
containerRef.scrollTop = nextTop
lastKnownScrollTop = nextTop
}
})
}
let pendingAutoPin = false
function scheduleAutoPinToBottom() {
if (!containerRef) return
if (pendingAutoPin) return
pendingAutoPin = true
const gen = scrollCompensationGen
// Flush in a microtask so adjustments land before the next paint.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingAutoPin = false
if (!containerRef) return
if (!autoScroll()) return
if (anchorLock()) return
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
if (containerRef.scrollTop !== maxScrollTop) {
containerRef.scrollTop = maxScrollTop
lastKnownScrollTop = maxScrollTop
}
})
}
function setShellRef(element: HTMLDivElement | null) {
shellRef = element || undefined
setShellElement(shellRef)
props.onShellElementChange?.(shellRef)
}
function setBottomSentinel(element: HTMLDivElement | null) {
setBottomSentinelSignal(element)
resolvePendingActiveScroll()
}
const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToKey: (key, opts) => {
if (typeof document === "undefined") return
const anchorId = getAnchorId(key)
const behavior = opts?.behavior ?? "smooth"
const block = opts?.block ?? "start"
const nextAutoScroll = opts?.setAutoScroll ?? false
setAutoScroll(nextAutoScroll)
if (!nextAutoScroll) {
if (anchorLock()) {
clearAnchorLock()
}
setAnchorLock({ key, block })
} else {
if (anchorLock()) {
clearAnchorLock()
}
}
const first = document.getElementById(anchorId)
first?.scrollIntoView({ block, behavior })
// When using virtualization, the placeholder height can be stale until the
// item mounts/measures. Re-run scrollIntoView() on the next frame to
// stabilize the final position.
requestAnimationFrame(() => {
const second = document.getElementById(anchorId)
second?.scrollIntoView({ block, behavior })
})
},
notifyContentRendered: () => handleContentRendered(),
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
getAutoScroll: () => autoScroll(),
getScrollElement: () => scrollElement(),
getShellElement: () => shellElement(),
}
createEffect(() => {
props.registerApi?.(api)
})
createEffect(() => {
props.registerState?.(state)
})
createEffect(() => {
const nextKey = props.resetKey?.()
if (nextKey === undefined) return
if (lastResetKey === undefined) {
lastResetKey = nextKey
return
}
if (nextKey === lastResetKey) return
lastResetKey = nextKey
// Reset internal state when consumers swap datasets (e.g. session switch).
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
clearScrollToBottomFrames()
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
pendingAutoPin = false
suppressAutoScrollOnce = false
pendingActiveScroll = false
pendingInitialScroll = true
setAnchorLock(null)
setActiveKey(null)
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setTopSentinelVisible(true)
setBottomSentinelVisible(true)
setAutoScroll(Boolean(initialAutoScroll()))
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
})
let lastActiveState = false
createEffect(() => {
const active = isActive()
if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
requestScrollToBottom(true)
// When switching back to a cached session pane, items can mount/measure
// after the initial scroll jump. Re-pin once layout settles so the
// viewport stays at the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scheduleAutoPinToBottom()
})
})
}
} else if (autoScroll() && scrollToBottomOnActivate()) {
pendingActiveScroll = true
}
lastActiveState = active
})
createEffect(() => {
const loading = isLoading()
if (loading) {
// Keep the initial scroll pending while loading so we can
// anchor to the bottom as soon as items appear.
pendingInitialScroll = true
}
if (!pendingInitialScroll) return
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || props.items().length === 0) return
if (!initialScrollToBottom()) {
// An outer component is responsible for restoring scroll.
pendingInitialScroll = false
return
}
// Ensure we're in follow-to-bottom mode for the initial position.
if (anchorLock()) {
clearAnchorLock()
}
setAutoScroll(true)
pendingInitialScroll = false
// Scroll synchronously so the first paint prefers bottom content.
scrollToBottom(true)
})
let previousFollowToken: string | number | undefined
createEffect(() => {
const token = props.followToken?.()
if (token === undefined) {
previousFollowToken = token
return
}
if (previousFollowToken === undefined) {
previousFollowToken = token
return
}
if (token === previousFollowToken) {
return
}
previousFollowToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) scheduleAnchorScroll(true)
})
// Drop anchor lock if the anchored key is removed.
createEffect(() => {
const lock = anchorLock()
if (!lock) return
const keys = props.items().map((item, idx) => props.getKey(item, idx))
if (!keys.includes(lock.key)) {
clearAnchorLock()
}
})
createEffect(() => {
if (props.items().length === 0) {
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
createEffect(() => {
const container = scrollElement()
const topTarget = topSentinel()
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
if (typeof IntersectionObserver === "undefined") return
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
const observer = new IntersectionObserver(
(entries) => {
let visibilityChanged = false
for (const entry of entries) {
if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
}
if (visibilityChanged) {
updateScrollIndicatorsFromVisibility()
}
},
{ root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` },
)
observer.observe(topTarget)
observer.observe(bottomTarget)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const container = scrollElement()
const items = props.items()
if (!container || items.length === 0) return
if (typeof document === "undefined") return
if (typeof IntersectionObserver === "undefined") return
const observer = new IntersectionObserver(
(entries) => {
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
best = entry
}
}
if (best) {
const anchorId = (best.target as HTMLElement).id
const key = getKeyFromAnchorId(anchorId)
setActiveKey((current) => (current === key ? current : key))
}
},
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
)
const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx)))
anchorIds.forEach((anchorId) => {
const anchor = document.getElementById(anchorId)
if (anchor) observer.observe(anchor)
})
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const key = activeKey()
props.onActiveKeyChange?.(key)
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
}
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
clearScrollToBottomFrames()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
})
const controls = () => {
if (props.renderControls) {
return props.renderControls(state, api)
}
// Avoid hardcoded user-visible strings; require consumers to supply
// localized aria labels when using the default controls.
if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) {
return null
}
const labelTop = props.scrollToTopAriaLabel()
const labelBottom = props.scrollToBottomAriaLabel()
return (
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label={labelBottom}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</Show>
)
}
return (
<div class="message-stream-shell" ref={setShellRef}>
<div
class="message-stream"
ref={setContainerRef}
onScroll={handleScroll}
onMouseUp={(event) => props.onMouseUp?.(event)}
onClick={(event) => props.onClick?.(event)}
>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
{props.renderBeforeItems?.()}
<Index each={props.items()}>
{(item, index) => {
const key = () => props.getKey(item(), index)
const anchorId = () => getAnchorId(key())
const overscanPx = props.overscanPx ?? 800
const suspendMeasurements = () => measurementsSuspended() || !isActive()
return (
<VirtualItem
id={anchorId()}
cacheKey={key()}
scrollContainer={scrollElement}
threshold={overscanPx}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={virtualizationEnabled}
suspendMeasurements={suspendMeasurements}
onHeightChange={(nextHeight, previousHeight) => {
const delta = nextHeight - previousHeight
// Follow mode: keep the viewport pinned to the bottom as
// items mount/measure and change height.
if (delta && autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
// Key-anchored mode: keep the target key in view when
// items above it mount/measure and shift layout.
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
// Free-scroll mode: if items above the viewport change height
// while scrolling upward, compensate scrollTop so visible
// content stays stable.
if (delta) {
scheduleScrollCompensation(key(), delta)
}
}}
>
{() => props.renderItem(item(), index)}
</VirtualItem>
)
}}
</Index>
<div ref={setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</div>
{controls()}
{props.renderOverlay?.()}
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>() const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600 const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 400 const MIN_PLACEHOLDER_HEIGHT = 32
const VISIBILITY_BUFFER_PX = 0 const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null type ObserverRoot = Element | Document | null
@@ -54,64 +54,11 @@ function shouldRenderEntry(entry: IntersectionObserverEntry) {
if (!rootBounds) { if (!rootBounds) {
return entry.isIntersecting return entry.isIntersecting
} }
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
// Above the root: compare bottom edge to root top. const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
if (entry.boundingClientRect.bottom < rootBounds.top) { if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
const distance = rootBounds.top - entry.boundingClientRect.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Below the root: compare top edge to root bottom.
if (entry.boundingClientRect.top > rootBounds.bottom) {
const distance = entry.boundingClientRect.top - rootBounds.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Overlapping the root bounds.
return true
}
function getViewportRect(): { top: number; bottom: number } {
if (typeof window === "undefined") {
return { top: 0, bottom: 0 }
}
return { top: 0, bottom: window.innerHeight }
}
function isRenderableRoot(root: ObserverRoot): boolean {
if (!root) return true
if (root instanceof Document) return true
if (typeof window === "undefined") return false
const element = root as Element
const style = window.getComputedStyle(element as Element)
if (style.display === "none" || style.visibility === "hidden") {
return false return false
} }
const rect = (element as Element).getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
function shouldRenderByRects(params: {
wrapperRect: DOMRect
rootRect: { top: number; bottom: number }
margin: number
}): boolean {
const { wrapperRect, rootRect, margin } = params
const threshold = margin + VISIBILITY_BUFFER_PX
// Above the root: compare bottom edge to root top.
if (wrapperRect.bottom < rootRect.top) {
const distance = rootRect.top - wrapperRect.bottom
return distance <= threshold
}
// Below the root: compare top edge to root bottom.
if (wrapperRect.top > rootRect.bottom) {
const distance = wrapperRect.top - rootRect.bottom
return distance <= threshold
}
return true return true
} }
@@ -156,7 +103,7 @@ function subscribeToSharedObserver(
interface VirtualItemProps { interface VirtualItemProps {
cacheKey: string cacheKey: string
children: JSX.Element | (() => JSX.Element) children: JSX.Element
scrollContainer?: Accessor<HTMLElement | undefined | null> scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number threshold?: number
minPlaceholderHeight?: number minPlaceholderHeight?: number
@@ -167,22 +114,14 @@ interface VirtualItemProps {
forceVisible?: Accessor<boolean> forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean> suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void onMeasured?: () => void
onHeightChange?: (nextHeight: number, previousHeight: number) => void
id?: string id?: string
} }
export default function VirtualItem(props: VirtualItemProps) { export default function VirtualItem(props: VirtualItemProps) {
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children) const resolved = resolveChildren(() => props.children)
const cachedHeight = sizeCache.get(props.cacheKey) const cachedHeight = sizeCache.get(props.cacheKey)
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT const [isIntersecting, setIsIntersecting] = createSignal(true)
// Default to hidden until we can determine visibility. const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
// This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false)
// Keep measuredHeight aligned with the *effective layout height* while hidden.
// When content first mounts, onHeightChange deltas should reflect the DOM's
// placeholder height (not 0), otherwise scroll compensation can overshoot.
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined) const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null let pendingVisibility: boolean | null = null
@@ -209,12 +148,12 @@ export default function VirtualItem(props: VirtualItemProps) {
}) })
} }
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const shouldHideContent = createMemo(() => { const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false if (!virtualizationEnabled()) return false
return !isIntersecting() return !isIntersecting()
}) })
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
let wrapperRef: HTMLDivElement | undefined let wrapperRef: HTMLDivElement | undefined
@@ -241,14 +180,9 @@ export default function VirtualItem(props: VirtualItemProps) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) { if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return return
} }
const before = measuredHeight()
const normalized = nextHeight const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight() const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
// Only keep the previous measurement when the element reports 0 height. const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
// Allow shrinkage so placeholder height matches real content height;
// keeping the max height can cause mount/unmount jitter near the
// virtualization boundary.
const shouldKeepPrevious = previous > 0 && normalized === 0
if (shouldKeepPrevious) { if (shouldKeepPrevious) {
if (!hasReportedMeasurement) { if (!hasReportedMeasurement) {
hasReportedMeasurement = true hasReportedMeasurement = true
@@ -257,7 +191,6 @@ export default function VirtualItem(props: VirtualItemProps) {
setHasMeasured(true) setHasMeasured(true)
sizeCache.set(props.cacheKey, previous) sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous) setMeasuredHeight(previous)
if (previous !== before) props.onHeightChange?.(previous, before)
return return
} }
if (normalized > 0) { if (normalized > 0) {
@@ -269,15 +202,11 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
setMeasuredHeight(normalized) setMeasuredHeight(normalized)
if (normalized !== before) props.onHeightChange?.(normalized, before)
} }
function updateMeasuredHeight() { function updateMeasuredHeight() {
if (!contentRef || measurementsSuspended()) return if (!contentRef || measurementsSuspended()) return
// Prefer subpixel-accurate height for scroll compensation. const next = contentRef.offsetHeight
// offsetHeight rounds to integers which can accumulate error.
const rect = contentRef.getBoundingClientRect()
const next = Math.max(0, Math.round(rect.height * 2) / 2)
if (next === measuredHeight()) return if (next === measuredHeight()) return
persistMeasurement(next) persistMeasurement(next)
} }
@@ -300,60 +229,15 @@ export default function VirtualItem(props: VirtualItemProps) {
function refreshIntersectionObserver(targetRoot: Element | Document | null) { function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver() cleanupIntersectionObserver()
if (!wrapperRef) { if (!wrapperRef) {
setIsIntersecting(false) setIsIntersecting(true)
return return
} }
if (typeof IntersectionObserver === "undefined") { if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true) setIsIntersecting(true)
return return
} }
const margin = props.threshold ?? DEFAULT_MARGIN_PX const margin = props.threshold ?? DEFAULT_MARGIN_PX
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
// If the scroll root is hidden / 0x0, IntersectionObserver can report
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
// which keeps heavy DOM alive in background tabs.
//
// In that state, force-hide and skip attaching the observer. When the
// pane becomes visible again, VirtualItem will re-run this setup and
// re-attach the observer.
const renderable = isRenderableRoot(targetRoot)
if (!renderable) {
setIsIntersecting(false)
return
}
// Avoid doing an eager geometry read here.
// During large list hydration / initial layout, wrapper rects can be
// transiently 0/incorrect and cause many offscreen items to mount.
// Rely on the observer callback (which we harden below) to determine
// visibility.
const wrapperEl = wrapperRef
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
// IntersectionObserver can produce transient false-positives during pane
// activation/layout transitions (e.g. `isIntersecting: true` for items far
// outside the scroll root). For element roots, prefer explicit rect math.
if (targetRoot && !(targetRoot instanceof Document)) {
// When rootBounds is null we cannot trust the entry; treat as hidden.
if (entry.rootBounds === null) {
queueVisibility(false)
return
}
try {
const rootRect = (targetRoot as Element).getBoundingClientRect()
const visible = shouldRenderByRects({
wrapperRect: wrapperEl.getBoundingClientRect(),
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin,
})
queueVisibility(visible)
return
} catch {
// Fall through to the entry-based heuristic.
}
}
const nextVisible = shouldRenderEntry(entry) const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible) queueVisibility(nextVisible)
}) })
@@ -399,7 +283,7 @@ export default function VirtualItem(props: VirtualItemProps) {
setMeasuredHeight(cached) setMeasuredHeight(cached)
setHasMeasured(true) setHasMeasured(true)
} else { } else {
setMeasuredHeight(fallbackPlaceholderHeight()) setMeasuredHeight(0)
setHasMeasured(false) setHasMeasured(false)
} }
}) })
@@ -436,9 +320,10 @@ export default function VirtualItem(props: VirtualItemProps) {
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ") const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => { const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null if (shouldHideContent()) return null
return resolveContent() return resolved()
}) })
return ( return (
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}> <div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
<div <div
@@ -455,3 +340,4 @@ export default function VirtualItem(props: VirtualItemProps) {
</div> </div>
) )
} }

View File

@@ -96,17 +96,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "The agent's roadmap for this session. Tracks tasks, subtasks, and their completion status.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Long-running processes started by the agent. You can monitor their output, stop, or terminate them.",
"instanceShell.rightPanel.sections.mcp": "MCP Servers", "instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol servers that extend the agent's capabilities with external tools and services.",
"instanceShell.rightPanel.sections.lsp": "LSP Servers", "instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocol servers providing code intelligence, diagnostics, and language-specific features.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins that customize the UI and server behavior, adding features beyond MCP and LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
"instanceShell.sessionChanges.loading": "Fetching session changes...", "instanceShell.sessionChanges.loading": "Fetching session changes...",

View File

@@ -23,6 +23,7 @@ export const messagingMessages = {
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",
"messageSection.quote.copied": "Copied!", "messageSection.quote.copied": "Copied!",
"messageSection.quote.copyFailed": "Copy failed", "messageSection.quote.copyFailed": "Copy failed",
"messageTimeline.ariaLabel": "Message timeline", "messageTimeline.ariaLabel": "Message timeline",
"messageTimeline.segment.user.label": "You", "messageTimeline.segment.user.label": "You",
"messageTimeline.segment.assistant.label": "Asst", "messageTimeline.segment.assistant.label": "Asst",
@@ -34,12 +35,13 @@ export const messagingMessages = {
"messageTimeline.tooltip.compaction.manual": "User Compaction", "messageTimeline.tooltip.compaction.manual": "User Compaction",
"messageTimeline.text.filePrefix": "[File] {filename}", "messageTimeline.text.filePrefix": "[File] {filename}",
"messageTimeline.text.attachment": "Attachment", "messageTimeline.text.attachment": "Attachment",
"messageBlock.tool.header": "Tool Call", "messageBlock.tool.header": "Tool Call",
"messageBlock.tool.unknown": "unknown", "messageBlock.tool.unknown": "unknown",
"messageBlock.tool.goToSession.label": "Go to Session", "messageBlock.tool.goToSession.label": "Go to Session",
"messageBlock.tool.goToSession.title": "Go to session", "messageBlock.tool.goToSession.title": "Go to session",
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet", "messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
"messageBlock.tool.deletePart.label": "Delete Part", "messageBlock.tool.deletePart.label": "Delete",
"messageBlock.tool.deletePart.deleting": "Deleting...", "messageBlock.tool.deletePart.deleting": "Deleting...",
"messageBlock.tool.deletePart.title": "Delete this tool call output", "messageBlock.tool.deletePart.title": "Delete this tool call output",
"messageBlock.tool.deletePart.failed.title": "Delete failed", "messageBlock.tool.deletePart.failed.title": "Delete failed",
@@ -69,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "You", "messageItem.speaker.you": "You",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revert", "messageItem.actions.revert": "Revert",
"messageItem.actions.revertTitle": "Undo changes up to here (deletes messages)", "messageItem.actions.revertTitle": "Revert to this message",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork from this message", "messageItem.actions.forkTitle": "Fork from this message",
"messageItem.actions.copy": "Copy", "messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message", "messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!", "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 items ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected items",
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
"messageSection.bulkDelete.moreOptionsTitle": "More options",
"messageSection.bulkDelete.selectionModeLabel": "Selection",
"messageSection.bulkDelete.selectionModeAll": "All",
"messageSection.bulkDelete.selectionModeTools": "Tools only",
"messageSection.bulkDelete.selectionHint.toggle": "Select item",
"messageSection.bulkDelete.selectionHint.range": "Select range",
"messageSection.bulkDelete.selectionHint.clear": "Clear Selection",
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
"messageSection.bulkDelete.failedTitle": "Delete failed",
"messageSection.bulkDelete.failedMessage": "Failed to delete selected items",
"messageItem.status.queued": "QUEUED", "messageItem.status.queued": "QUEUED",
"messageItem.status.generating": "Generating...", "messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...", "messageItem.status.sending": "Sending...",
"messageItem.status.failedToSend": "Message failed to send", "messageItem.status.failedToSend": "Message failed to send",
"messagePart.actions.delete": "Delete Part", "messagePart.actions.delete": "Delete",
"messagePart.actions.deleting": "Deleting...", "messagePart.actions.deleting": "Deleting...",
"messagePart.actions.deleteTitle": "Delete this item", "messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed", "messagePart.actions.deleteFailedTitle": "Delete failed",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Stop failed", "sessionView.alerts.abortFailed.title": "Stop failed",
"sessionView.alerts.revertFailed.message": "Failed to revert to message", "sessionView.alerts.revertFailed.message": "Failed to revert to message",
"sessionView.alerts.revertFailed.title": "Revert failed", "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.message": "Failed to fork session",
"sessionView.alerts.forkFailed.title": "Fork failed", "sessionView.alerts.forkFailed.title": "Fork failed",
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text", "sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",

View File

@@ -48,7 +48,7 @@ export const instanceMessages = {
"instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos", "instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos",
"instanceShell.commandPalette.button": "Paleta de comandos", "instanceShell.commandPalette.button": "Paleta de comandos",
"instanceShell.connection.ariaLabel": "Conexión {status}", "instanceShell.connection.ariaLabel": "Connection {status}",
"instanceShell.connection.connected": "Conectada", "instanceShell.connection.connected": "Conectada",
"instanceShell.connection.connecting": "Conectando...", "instanceShell.connection.connecting": "Conectando...",
"instanceShell.connection.disconnected": "Desconectada", "instanceShell.connection.disconnected": "Desconectada",
@@ -93,22 +93,16 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Hoja de ruta del agente para esta sesión. Realiza el seguimiento de tareas, subtareas y su estado de finalización.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Procesos de larga duración iniciados por el agente. Puedes supervisar su salida, detenerlos o terminarlos.",
"instanceShell.rightPanel.sections.mcp": "Servidores MCP", "instanceShell.rightPanel.sections.mcp": "Servidores MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Servidores del Model Context Protocol (MCP) que amplían las capacidades del agente con herramientas y servicios externos.",
"instanceShell.rightPanel.sections.lsp": "Servidores LSP", "instanceShell.rightPanel.sections.lsp": "Servidores LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Servidores del Language Server Protocol (LSP) que proporcionan inteligencia de código, diagnósticos y funciones específicas del lenguaje.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.", "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.",
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...", "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...",
"instanceShell.sessionChanges.empty": "Aún no hay cambios.", "instanceShell.sessionChanges.empty": "Aun no hay cambios.",
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Ir a sesión", "messageBlock.tool.goToSession.label": "Ir a sesión",
"messageBlock.tool.goToSession.title": "Ir a la 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.goToSession.unavailableTitle": "La sesión aún no está disponible",
"messageBlock.tool.deletePart.label": "Eliminar parte", "messageBlock.tool.deletePart.label": "Eliminar",
"messageBlock.tool.deletePart.deleting": "Eliminando...", "messageBlock.tool.deletePart.deleting": "Eliminando...",
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta", "messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
"messageBlock.tool.deletePart.failed.title": "Error al eliminar", "messageBlock.tool.deletePart.failed.title": "Error al eliminar",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "Tú", "messageItem.speaker.you": "Tú",
"messageItem.speaker.assistant": "Asistente", "messageItem.speaker.assistant": "Asistente",
"messageItem.actions.revert": "Revertir", "messageItem.actions.revert": "Revertir",
"messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)", "messageItem.actions.revertTitle": "Revertir a este mensaje",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork desde este mensaje", "messageItem.actions.forkTitle": "Fork desde este mensaje",
"messageItem.actions.copy": "Copiar", "messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje", "messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!", "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": "Elementos seleccionados ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar elementos seleccionados",
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
"messageSection.bulkDelete.moreOptionsTitle": "Más opciones",
"messageSection.bulkDelete.selectionModeLabel": "Selección",
"messageSection.bulkDelete.selectionModeAll": "Todo",
"messageSection.bulkDelete.selectionModeTools": "Solo herramientas",
"messageSection.bulkDelete.selectionHint.toggle": "Seleccionar elemento",
"messageSection.bulkDelete.selectionHint.range": "Seleccionar rango",
"messageSection.bulkDelete.selectionHint.clear": "Borrar selección",
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los elementos seleccionados",
"messageItem.status.queued": "EN COLA", "messageItem.status.queued": "EN COLA",
"messageItem.status.generating": "Generando...", "messageItem.status.generating": "Generando...",
"messageItem.status.sending": "Enviando...", "messageItem.status.sending": "Enviando...",
"messageItem.status.failedToSend": "No se pudo enviar el mensaje", "messageItem.status.failedToSend": "No se pudo enviar el mensaje",
"messagePart.actions.delete": "Eliminar parte", "messagePart.actions.delete": "Eliminar",
"messagePart.actions.deleting": "Eliminando...", "messagePart.actions.deleting": "Eliminando...",
"messagePart.actions.deleteTitle": "Eliminar este elemento", "messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar", "messagePart.actions.deleteFailedTitle": "Error al eliminar",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "No se pudo detener", "sessionView.alerts.abortFailed.title": "No se pudo detener",
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje", "sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
"sessionView.alerts.revertFailed.title": "No se pudo revertir", "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.message": "No se pudo hacer fork de la sesión",
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork", "sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado", "sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",

View File

@@ -94,17 +94,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Feuille de route de l'agent pour cette session. Suit les tâches et leur statut d'achèvement.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Processus longs démarrés par l'agent. Vous pouvez surveiller leur sortie, les arrêter ou les terminer.",
"instanceShell.rightPanel.sections.mcp": "Serveurs MCP", "instanceShell.rightPanel.sections.mcp": "Serveurs MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Serveurs du protocole Model Context Protocol qui étendent les capacités de l'agent avec des outils externes.",
"instanceShell.rightPanel.sections.lsp": "Serveurs LSP", "instanceShell.rightPanel.sections.lsp": "Serveurs LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Serveurs du protocole Language Server Protocol fournissant l'intelligence de code et les diagnostics.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.",
"instanceShell.sessionChanges.loading": "Récupération des changements...", "instanceShell.sessionChanges.loading": "Récupération des changements...",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Aller à la session", "messageBlock.tool.goToSession.label": "Aller à la session",
"messageBlock.tool.goToSession.title": "Aller à la session", "messageBlock.tool.goToSession.title": "Aller à la session",
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible", "messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
"messageBlock.tool.deletePart.label": "Supprimer la partie", "messageBlock.tool.deletePart.label": "Supprimer",
"messageBlock.tool.deletePart.deleting": "Suppression...", "messageBlock.tool.deletePart.deleting": "Suppression...",
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil", "messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
"messageBlock.tool.deletePart.failed.title": "Échec de suppression", "messageBlock.tool.deletePart.failed.title": "Échec de suppression",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "Vous", "messageItem.speaker.you": "Vous",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revenir", "messageItem.actions.revert": "Revenir",
"messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)", "messageItem.actions.revertTitle": "Revenir à ce message",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork depuis ce message", "messageItem.actions.forkTitle": "Fork depuis ce message",
"messageItem.actions.copy": "Copier", "messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message", "messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !", "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": "Éléments sélectionnés ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les éléments sélectionnés",
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
"messageSection.bulkDelete.moreOptionsTitle": "Plus d'options",
"messageSection.bulkDelete.selectionModeLabel": "Sélection",
"messageSection.bulkDelete.selectionModeAll": "Tous",
"messageSection.bulkDelete.selectionModeTools": "Outils uniquement",
"messageSection.bulkDelete.selectionHint.toggle": "Selectionner un element",
"messageSection.bulkDelete.selectionHint.range": "Selectionner une plage",
"messageSection.bulkDelete.selectionHint.clear": "Effacer la selection",
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les éléments sélectionnés",
"messageItem.status.queued": "EN FILE", "messageItem.status.queued": "EN FILE",
"messageItem.status.generating": "Génération...", "messageItem.status.generating": "Génération...",
"messageItem.status.sending": "Envoi...", "messageItem.status.sending": "Envoi...",
"messageItem.status.failedToSend": "Échec de l'envoi du message", "messageItem.status.failedToSend": "Échec de l'envoi du message",
"messagePart.actions.delete": "Supprimer la partie", "messagePart.actions.delete": "Supprimer",
"messagePart.actions.deleting": "Suppression...", "messagePart.actions.deleting": "Suppression...",
"messagePart.actions.deleteTitle": "Supprimer cet élément", "messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression", "messagePart.actions.deleteFailedTitle": "Échec de suppression",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt", "sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message", "sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
"sessionView.alerts.revertFailed.title": "Échec du retour", "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.message": "Impossible de forker la session",
"sessionView.alerts.forkFailed.title": "Échec du fork", "sessionView.alerts.forkFailed.title": "Échec du fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé", "sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",

View File

@@ -94,17 +94,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan": "計画",
"instanceShell.rightPanel.sections.plan.tooltip": "このセッションにおけるエージェントのロードマップ。タスクやサブタスク、および完了状況を追跡します。",
"instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "エージェントが開始した長時間実行プロセス。出力を監視し、停止または終了できます。",
"instanceShell.rightPanel.sections.mcp": "MCP サーバー", "instanceShell.rightPanel.sections.mcp": "MCP サーバー",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol (MCP) サーバー。外部ツールやサービスでエージェントの機能を拡張します。",
"instanceShell.rightPanel.sections.lsp": "LSP サーバー", "instanceShell.rightPanel.sections.lsp": "LSP サーバー",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocolサーバーがコードインテリジェンス、診断、言語固有の機能を提供します。",
"instanceShell.rightPanel.sections.plugins": "プラグイン", "instanceShell.rightPanel.sections.plugins": "プラグイン",
"instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。",
"instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。",
"instanceShell.sessionChanges.loading": "変更を取得中...", "instanceShell.sessionChanges.loading": "変更を取得中...",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "セッションへ移動", "messageBlock.tool.goToSession.label": "セッションへ移動",
"messageBlock.tool.goToSession.title": "セッションへ移動", "messageBlock.tool.goToSession.title": "セッションへ移動",
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません", "messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
"messageBlock.tool.deletePart.label": "パートを削除", "messageBlock.tool.deletePart.label": "削除",
"messageBlock.tool.deletePart.deleting": "削除中...", "messageBlock.tool.deletePart.deleting": "削除中...",
"messageBlock.tool.deletePart.title": "このツール出力を削除", "messageBlock.tool.deletePart.title": "このツール出力を削除",
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました", "messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "あなた", "messageItem.speaker.you": "あなた",
"messageItem.speaker.assistant": "アシスタント", "messageItem.speaker.assistant": "アシスタント",
"messageItem.actions.revert": "戻す", "messageItem.actions.revert": "戻す",
"messageItem.actions.revertTitle": "ここまでの変更を元に戻す(メッセージを削除)", "messageItem.actions.revertTitle": "こメッセージまで戻す",
"messageItem.actions.fork": "フォーク", "messageItem.actions.fork": "フォーク",
"messageItem.actions.forkTitle": "このメッセージからフォーク", "messageItem.actions.forkTitle": "このメッセージからフォーク",
"messageItem.actions.copy": "コピー", "messageItem.actions.copy": "コピー",
"messageItem.actions.copyTitle": "メッセージをコピー", "messageItem.actions.copyTitle": "メッセージをコピー",
"messageItem.actions.copied": "コピーしました!", "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.moreOptionsTitle": "その他のオプション",
"messageSection.bulkDelete.selectionModeLabel": "選択",
"messageSection.bulkDelete.selectionModeAll": "すべて",
"messageSection.bulkDelete.selectionModeTools": "ツールのみ",
"messageSection.bulkDelete.selectionHint.toggle": "項目を選択",
"messageSection.bulkDelete.selectionHint.range": "範囲を選択",
"messageSection.bulkDelete.selectionHint.clear": "選択を解除",
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
"messageSection.bulkDelete.failedMessage": "選択した項目の削除に失敗しました",
"messageItem.status.queued": "待機中", "messageItem.status.queued": "待機中",
"messageItem.status.generating": "生成中...", "messageItem.status.generating": "生成中...",
"messageItem.status.sending": "送信中...", "messageItem.status.sending": "送信中...",
"messageItem.status.failedToSend": "メッセージの送信に失敗しました", "messageItem.status.failedToSend": "メッセージの送信に失敗しました",
"messagePart.actions.delete": "パートを削除", "messagePart.actions.delete": "削除",
"messagePart.actions.deleting": "削除中...", "messagePart.actions.deleting": "削除中...",
"messagePart.actions.deleteTitle": "この項目を削除", "messagePart.actions.deleteTitle": "この項目を削除",
"messagePart.actions.deleteFailedTitle": "削除に失敗しました", "messagePart.actions.deleteFailedTitle": "削除に失敗しました",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "停止に失敗", "sessionView.alerts.abortFailed.title": "停止に失敗",
"sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした", "sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした",
"sessionView.alerts.revertFailed.title": "復元に失敗", "sessionView.alerts.revertFailed.title": "復元に失敗",
"sessionView.alerts.deleteUpToFailed.message": "メッセージの削除に失敗しました",
"sessionView.alerts.deleteUpToFailed.title": "削除に失敗しました",
"sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました", "sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました",
"sessionView.alerts.forkFailed.title": "フォークに失敗", "sessionView.alerts.forkFailed.title": "フォークに失敗",
"sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開", "sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開",

View File

@@ -94,17 +94,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan": "План",
"instanceShell.rightPanel.sections.plan.tooltip": "Дорожная карта агента для этой сессии. Отслеживает задачи и их статус выполнения.", "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell",
"instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые оболочки",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Долгоработающие процессы, запущенные агентом. Вы можете следить за их выводом, останавливать или завершать их.",
"instanceShell.rightPanel.sections.mcp": "MCP-серверы", "instanceShell.rightPanel.sections.mcp": "MCP-серверы",
"instanceShell.rightPanel.sections.mcp.tooltip": "Серверы протокола Model Context Protocol, расширяющие возможности агента внешними инструментами.",
"instanceShell.rightPanel.sections.lsp": "LSP-серверы", "instanceShell.rightPanel.sections.lsp": "LSP-серверы",
"instanceShell.rightPanel.sections.lsp.tooltip": "Серверы протокола Language Server Protocol, обеспечивающие интеллектуальную поддержку кода и диагностику.",
"instanceShell.rightPanel.sections.plugins": "Плагины", "instanceShell.rightPanel.sections.plugins": "Плагины",
"instanceShell.rightPanel.sections.plugins.tooltip": "Плагины, настраивающие поведение интерфейса и сервера, добавляющие функции поверх MCP и LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.",
"instanceShell.sessionChanges.loading": "Загрузка изменений...", "instanceShell.sessionChanges.loading": "Загрузка изменений...",
@@ -134,7 +128,7 @@ export const instanceMessages = {
"versionPill.uiWithVersion": "UI {version}", "versionPill.uiWithVersion": "UI {version}",
"versionPill.source": " ({source})", "versionPill.source": " ({source})",
"opencodeBinarySelector.title": "Бинарник OpenCode", "opencodeBinarySelector.title": "OpenCode Binary",
"opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать", "opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать",
"opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…", "opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…",
"opencodeBinarySelector.actions.add": "Добавить", "opencodeBinarySelector.actions.add": "Добавить",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Перейти к сессии", "messageBlock.tool.goToSession.label": "Перейти к сессии",
"messageBlock.tool.goToSession.title": "Перейти к сессии", "messageBlock.tool.goToSession.title": "Перейти к сессии",
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна", "messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
"messageBlock.tool.deletePart.label": "Удалить часть", "messageBlock.tool.deletePart.label": "Удалить",
"messageBlock.tool.deletePart.deleting": "Удаление...", "messageBlock.tool.deletePart.deleting": "Удаление...",
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента", "messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления", "messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "Вы", "messageItem.speaker.you": "Вы",
"messageItem.speaker.assistant": "Ассистент", "messageItem.speaker.assistant": "Ассистент",
"messageItem.actions.revert": "Откатить", "messageItem.actions.revert": "Откатить",
"messageItem.actions.revertTitle": "Отменить изменения до этого места (удалит сообщения)", "messageItem.actions.revertTitle": "Откатиться к этому сообщению",
"messageItem.actions.fork": "Форк", "messageItem.actions.fork": "Форк",
"messageItem.actions.forkTitle": "Форкнуть от этого сообщения", "messageItem.actions.forkTitle": "Форкнуть от этого сообщения",
"messageItem.actions.copy": "Копировать", "messageItem.actions.copy": "Копировать",
"messageItem.actions.copyTitle": "Копировать сообщение", "messageItem.actions.copyTitle": "Копировать сообщение",
"messageItem.actions.copied": "Скопировано!", "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.moreOptionsTitle": "Больше настроек",
"messageSection.bulkDelete.selectionModeLabel": "Выбор",
"messageSection.bulkDelete.selectionModeAll": "Все",
"messageSection.bulkDelete.selectionModeTools": "Только инструменты",
"messageSection.bulkDelete.selectionHint.toggle": "Выбрать элемент",
"messageSection.bulkDelete.selectionHint.range": "Выбрать диапазон",
"messageSection.bulkDelete.selectionHint.clear": "Очистить выбор",
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные элементы",
"messageItem.status.queued": "В ОЧЕРЕДИ", "messageItem.status.queued": "В ОЧЕРЕДИ",
"messageItem.status.generating": "Генерация…", "messageItem.status.generating": "Генерация…",
"messageItem.status.sending": "Отправка…", "messageItem.status.sending": "Отправка…",
"messageItem.status.failedToSend": "Не удалось отправить сообщение", "messageItem.status.failedToSend": "Не удалось отправить сообщение",
"messagePart.actions.delete": "Удалить часть", "messagePart.actions.delete": "Удалить",
"messagePart.actions.deleting": "Удаление...", "messagePart.actions.deleting": "Удаление...",
"messagePart.actions.deleteTitle": "Удалить этот элемент", "messagePart.actions.deleteTitle": "Удалить этот элемент",
"messagePart.actions.deleteFailedTitle": "Ошибка удаления", "messagePart.actions.deleteFailedTitle": "Ошибка удаления",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Не удалось остановить", "sessionView.alerts.abortFailed.title": "Не удалось остановить",
"sessionView.alerts.revertFailed.message": "Не удалось откатиться к сообщению", "sessionView.alerts.revertFailed.message": "Не удалось откатиться к сообщению",
"sessionView.alerts.revertFailed.title": "Не удалось откатиться", "sessionView.alerts.revertFailed.title": "Не удалось откатиться",
"sessionView.alerts.deleteUpToFailed.message": "Не удалось удалить сообщения",
"sessionView.alerts.deleteUpToFailed.title": "Ошибка удаления",
"sessionView.alerts.forkFailed.message": "Не удалось форкнуть сессию", "sessionView.alerts.forkFailed.message": "Не удалось форкнуть сессию",
"sessionView.alerts.forkFailed.title": "Не удалось форкнуть", "sessionView.alerts.forkFailed.title": "Не удалось форкнуть",
"sessionView.attachments.expandPastedTextAriaLabel": "Развернуть вставленный текст", "sessionView.attachments.expandPastedTextAriaLabel": "Развернуть вставленный текст",

View File

@@ -94,17 +94,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan": "计划",
"instanceShell.rightPanel.sections.plan.tooltip": "代理的路线图。跟踪任务、子任务及其完成状态。",
"instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "代理启动的后台进程。您可以监控其输出、停止或终止它们。",
"instanceShell.rightPanel.sections.mcp": "MCP 服务器", "instanceShell.rightPanel.sections.mcp": "MCP 服务器",
"instanceShell.rightPanel.sections.mcp.tooltip": "模型上下文协议服务器,使用外部工具和服务扩展代理能力。",
"instanceShell.rightPanel.sections.lsp": "LSP 服务器", "instanceShell.rightPanel.sections.lsp": "LSP 服务器",
"instanceShell.rightPanel.sections.lsp.tooltip": "语言服务器协议服务器,提供代码智能、诊断和语言特定的功能。",
"instanceShell.rightPanel.sections.plugins": "插件", "instanceShell.rightPanel.sections.plugins": "插件",
"instanceShell.rightPanel.sections.plugins.tooltip": "自定义 UI 和服务器行为的插件,添加超出 MCP 和 LSP 的功能。",
"instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。",
"instanceShell.sessionChanges.loading": "正在获取会话更改...", "instanceShell.sessionChanges.loading": "正在获取会话更改...",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "前往会话", "messageBlock.tool.goToSession.label": "前往会话",
"messageBlock.tool.goToSession.title": "前往会话", "messageBlock.tool.goToSession.title": "前往会话",
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用", "messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
"messageBlock.tool.deletePart.label": "删除部分", "messageBlock.tool.deletePart.label": "删除",
"messageBlock.tool.deletePart.deleting": "正在删除...", "messageBlock.tool.deletePart.deleting": "正在删除...",
"messageBlock.tool.deletePart.title": "删除此工具输出", "messageBlock.tool.deletePart.title": "删除此工具输出",
"messageBlock.tool.deletePart.failed.title": "删除失败", "messageBlock.tool.deletePart.failed.title": "删除失败",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "你", "messageItem.speaker.you": "你",
"messageItem.speaker.assistant": "助手", "messageItem.speaker.assistant": "助手",
"messageItem.actions.revert": "回退", "messageItem.actions.revert": "回退",
"messageItem.actions.revertTitle": "撤销到此处的更改(会删除消息", "messageItem.actions.revertTitle": "回退到这条消息",
"messageItem.actions.fork": "分叉", "messageItem.actions.fork": "分叉",
"messageItem.actions.forkTitle": "从这条消息分叉", "messageItem.actions.forkTitle": "从这条消息分叉",
"messageItem.actions.copy": "复制", "messageItem.actions.copy": "复制",
"messageItem.actions.copyTitle": "复制消息", "messageItem.actions.copyTitle": "复制消息",
"messageItem.actions.copied": "已复制!", "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.moreOptionsTitle": "更多选项",
"messageSection.bulkDelete.selectionModeLabel": "选择",
"messageSection.bulkDelete.selectionModeAll": "全部",
"messageSection.bulkDelete.selectionModeTools": "仅工具",
"messageSection.bulkDelete.selectionHint.toggle": "选择项目",
"messageSection.bulkDelete.selectionHint.range": "选择范围",
"messageSection.bulkDelete.selectionHint.clear": "清除选择",
"messageSection.bulkDelete.cancelTitle": "取消选择",
"messageSection.bulkDelete.failedTitle": "删除失败",
"messageSection.bulkDelete.failedMessage": "无法删除已选择的项目",
"messageItem.status.queued": "排队中", "messageItem.status.queued": "排队中",
"messageItem.status.generating": "正在生成...", "messageItem.status.generating": "正在生成...",
"messageItem.status.sending": "正在发送...", "messageItem.status.sending": "正在发送...",
"messageItem.status.failedToSend": "消息发送失败", "messageItem.status.failedToSend": "消息发送失败",
"messagePart.actions.delete": "删除部分", "messagePart.actions.delete": "删除",
"messagePart.actions.deleting": "正在删除...", "messagePart.actions.deleting": "正在删除...",
"messagePart.actions.deleteTitle": "删除此项", "messagePart.actions.deleteTitle": "删除此项",
"messagePart.actions.deleteFailedTitle": "删除失败", "messagePart.actions.deleteFailedTitle": "删除失败",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "停止失败", "sessionView.alerts.abortFailed.title": "停止失败",
"sessionView.alerts.revertFailed.message": "回退到消息失败", "sessionView.alerts.revertFailed.message": "回退到消息失败",
"sessionView.alerts.revertFailed.title": "回退失败", "sessionView.alerts.revertFailed.title": "回退失败",
"sessionView.alerts.deleteUpToFailed.message": "无法删除消息",
"sessionView.alerts.deleteUpToFailed.title": "删除失败",
"sessionView.alerts.forkFailed.message": "分叉会话失败", "sessionView.alerts.forkFailed.message": "分叉会话失败",
"sessionView.alerts.forkFailed.title": "分叉失败", "sessionView.alerts.forkFailed.title": "分叉失败",
"sessionView.attachments.expandPastedTextAriaLabel": "展开粘贴的文本", "sessionView.attachments.expandPastedTextAriaLabel": "展开粘贴的文本",

View File

@@ -1,29 +0,0 @@
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
if (!error) {
return fallbackMessage
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
return (parsed as any).error
}
} catch {
// ignore JSON parse errors
}
return raw
}
export function isMissingBinaryMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}

View File

@@ -127,23 +127,17 @@ async function ensureLanguages(content: string) {
if (highlightSuppressed) { if (highlightSuppressed) {
return return
} }
// Parse code fences to extract language tokens
// Extract code-fence language tokens via `marked` so we correctly handle code blocks // Updated regex to capture optional language tokens and handle trailing annotations
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend const codeBlockRegex = /```[ \t]*([A-Za-z0-9_.+#-]+)?[^`]*?```/g
// to miss these and prevent languages from loading.
const foundLanguages = new Set<string>() const foundLanguages = new Set<string>()
try { let match
const tokens = marked.lexer(content) as any
marked.walkTokens(tokens, (token: any) => { while ((match = codeBlockRegex.exec(content)) !== null) {
if (token?.type !== "code") return const langToken = match[1]
const langToken = typeof token.lang === "string" ? token.lang : "" if (langToken && langToken.trim()) {
if (langToken.trim()) { foundLanguages.add(langToken.trim())
foundLanguages.add(langToken.trim()) }
}
})
} catch {
// If tokenization fails for any reason, skip language preloading.
return
} }
// Queue language loading tasks // Queue language loading tasks

View File

@@ -1,66 +0,0 @@
import type { ClientPart } from "../types/message"
/**
* Count the total character content of a message part.
*
* Used by both the xray histogram overlay (message-timeline) and the
* bulk-delete toolbar token pills (message-section) so both surfaces
* derive token estimates from the same logic.
*
* Note: For tool parts we intentionally only count `state.input` and
* `state.output`. We exclude `state.metadata` from token estimation since
* metadata can contain large or verbose diagnostic payloads that are not
* representative of model context.
*/
export function getPartCharCount(part: ClientPart): number {
if (!part) return 0
let count = 0
if (typeof (part as any).text === "string") {
count += (part as any).text.length
}
if (part.type === "tool") {
const state = (part as any).state
// Tool calls may be compacted server-side. When that happens we treat the
// tool payload as effectively absent from context for token estimation.
const compacted = (state as any)?.time?.compacted
if (compacted !== undefined && compacted !== null) {
return 0
}
if (state) {
if (state.input) {
try {
count += JSON.stringify(state.input).length
} catch {}
}
if (state.output) {
if (typeof state.output === "string") {
count += state.output.length
} else {
try {
count += JSON.stringify(state.output).length
} catch {}
}
}
}
}
if (Array.isArray((part as any).content)) {
count += (part as any).content.reduce((acc: number, entry: unknown) => {
if (typeof entry === "string") return acc + entry.length
if (entry && typeof entry === "object") {
let entryCount = (String((entry as any).text || "")).length + (String((entry as any).value || "")).length
if (Array.isArray((entry as any).content)) {
entryCount += (entry as any).content.reduce((innerAcc: number, sub: unknown) => {
if (typeof sub === "string") return innerAcc + sub.length
return innerAcc + (String((sub as any)?.text || "")).length
}, 0)
}
return acc + entryCount
}
return acc
}, 0)
}
return count
}

View File

@@ -35,7 +35,6 @@ import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestio
import { clearCacheForInstance } from "../lib/global-cache" import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
import { showWorkspaceLaunchError } from "./launch-errors"
const log = getLogger("api") const log = getLogger("api")
@@ -373,7 +372,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
break break
case "workspace.error": case "workspace.error":
upsertWorkspace(event.workspace) upsertWorkspace(event.workspace)
showWorkspaceLaunchError(event.workspace)
break break
case "workspace.stopped": case "workspace.stopped":
releaseInstanceResources(event.workspaceId) releaseInstanceResources(event.workspaceId)

View File

@@ -1,53 +0,0 @@
import { createSignal } from "solid-js"
import type { WorkspaceDescriptor } from "../../../server/src/api-types"
import { tGlobal } from "../lib/i18n"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "../lib/launch-errors"
type LaunchErrorSource = "create" | "workspace"
export interface LaunchErrorState {
source: LaunchErrorSource
message: string
binaryPath: string
missingBinary: boolean
instanceId?: string
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
// Avoid spamming the user with the same modal on repeated events.
const lastWorkspaceErrorByInstanceId = new Map<string, string>()
export function showLaunchError(next: LaunchErrorState) {
setLaunchError(next)
}
export function clearLaunchError() {
setLaunchError(null)
}
export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) {
const instanceId = workspace.id
const rawMessage = workspace.error
const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage"))
const previous = lastWorkspaceErrorByInstanceId.get(instanceId)
if (previous && previous === message) {
return
}
lastWorkspaceErrorByInstanceId.set(instanceId, message)
const binaryPath = (workspace.binaryLabel || workspace.binaryId || "opencode").trim() || "opencode"
const missingBinary = isMissingBinaryMessage(message)
showLaunchError({
source: "workspace",
instanceId,
message,
binaryPath,
missingBinary,
})
}
export { launchError }

View File

@@ -117,7 +117,6 @@ export function applyPartDeltaV2(
partId: input.partId, partId: input.partId,
field: input.field, field: input.field,
delta: input.delta, delta: input.delta,
bumpSessionRevision: false,
}) })
} }

View File

@@ -189,14 +189,7 @@ export interface InstanceMessageStore {
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
upsertMessage: (input: MessageUpsertInput) => void upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void applyPartUpdate: (input: PartUpdateInput) => void
applyPartDelta: (input: { applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void
messageId: string
partId: string
field: string
delta: string
bumpRevision?: boolean
bumpSessionRevision: boolean
}) => void
removeMessage: (messageId: string) => void removeMessage: (messageId: string) => void
removeMessagePart: (messageId: string, partId: string) => void removeMessagePart: (messageId: string, partId: string) => void
bufferPendingPart: (entry: PendingPartEntry) => void bufferPendingPart: (entry: PendingPartEntry) => void
@@ -218,9 +211,6 @@ export interface InstanceMessageStore {
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
getSessionRevision: (sessionId: string) => number getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[] getSessionMessageIds: (sessionId: string) => string[]
// Index of the most recent message in the session that contains a compaction part.
// Returns -1 if there has been no compaction.
getLastCompactionMessageIndex: (sessionId: string) => number
getMessage: (messageId: string) => MessageRecord | undefined getMessage: (messageId: string) => MessageRecord | undefined
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
clearSession: (sessionId: string) => void clearSession: (sessionId: string) => void
@@ -234,24 +224,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const messageInfoCache = new Map<string, MessageInfo>() const messageInfoCache = new Map<string, MessageInfo>()
function getLastCompactionMessageIndex(sessionId: string): number {
if (!sessionId) return -1
const ids = state.sessions[sessionId]?.messageIds ?? []
// Scan from the end: we only care about the most recent compaction.
for (let i = ids.length - 1; i >= 0; i--) {
const messageId = ids[i]
const record = state.messages[messageId]
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) continue
for (const partId of record.partIds) {
const part = record.parts[partId]?.data
if ((part as any)?.type === "compaction") {
return i
}
}
}
return -1
}
function isCompletedTodoPart(part: ClientPart | undefined): boolean { function isCompletedTodoPart(part: ClientPart | undefined): boolean {
if (!part || (part as any).type !== "tool") { if (!part || (part as any).type !== "tool") {
return false return false
@@ -626,14 +598,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bumpSessionRevision(message.sessionId) bumpSessionRevision(message.sessionId)
} }
function applyPartDelta(input: { function applyPartDelta(input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) {
messageId: string
partId: string
field: string
delta: string
bumpRevision?: boolean
bumpSessionRevision?: boolean
}) {
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
return return
} }
@@ -667,7 +632,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}), }),
) )
if (applied && (input.bumpSessionRevision ?? true)) { if (applied) {
bumpSessionRevision(message.sessionId) bumpSessionRevision(message.sessionId)
} }
} }
@@ -1159,8 +1124,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
function clearInstance() { function clearInstance() {
messageInfoCache.clear() messageInfoCache.clear()
setState(reconcile(createInitialState(instanceId))) setState(reconcile(createInitialState(instanceId)))
} }
return { return {
@@ -1193,11 +1158,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setScrollSnapshot, setScrollSnapshot,
getScrollSnapshot, getScrollSnapshot,
getSessionRevision: getSessionRevisionValue, getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastCompactionMessageIndex, getMessage: (messageId: string) => state.messages[messageId],
getMessage: (messageId: string) => state.messages[messageId], getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId], clearSession,
clearSession, clearInstance,
clearInstance,
} }
} }

View File

@@ -7,7 +7,7 @@ import { providers, sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models" import { getDefaultModel, isModelValid } from "./session-models"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { removeMessagePartV2 } from "./message-v2/bridge"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api" import { requestData } from "../lib/opencode-api"
@@ -439,33 +439,8 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
updateSessionInfo(instanceId, sessionId) 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 { export {
abortSession, abortSession,
deleteMessage,
deleteMessagePart, deleteMessagePart,
executeCustomCommand, executeCustomCommand,
renameSession, renameSession,

View File

@@ -40,7 +40,7 @@ import {
} from "./instances" } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { tGlobal } from "../lib/i18n" import { tGlobal } from "../lib/i18n"
@@ -108,8 +108,6 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle" const current = session.status ?? "idle"
if (current === status) return false if (current === status) return false
@@ -119,17 +117,7 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
} }
session.status = status session.status = status
// Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition.
if (session.parentId && status === "working" && current !== "working") {
parentToExpand = session.parentId
}
}) })
if (parentToExpand) {
ensureSessionParentExpanded(instanceId, parentToExpand)
}
} }
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> { async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
@@ -170,7 +158,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
let updatedInstanceSessions: Map<string, Session> | undefined let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -187,19 +174,11 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
instanceSessions.set(sessionId, merged) instanceSessions.set(sessionId, merged)
next.set(instanceId, instanceSessions) next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions updatedInstanceSessions = instanceSessions
if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") {
shouldExpandParent = merged.parentId
}
return next return next
}) })
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
if (shouldExpandParent) {
ensureSessionParentExpanded(instanceId, shouldExpandParent)
}
return fetched return fetched
} catch (error) { } catch (error) {
log.error("Failed to fetch session info", error) log.error("Failed to fetch session info", error)

View File

@@ -347,23 +347,10 @@ function clearActiveParentSession(instanceId: string): void {
} }
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void { function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
if (session.status === status) return false if (session.status === status) return false
const previous = session.status
session.status = status session.status = status
// If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition.
if (session.parentId && status === "working" && previous !== "working") {
parentToExpand = session.parentId
}
}) })
if (parentToExpand) {
ensureSessionParentExpanded(instanceId, parentToExpand)
}
} }
function getActiveParentSession(instanceId: string): Session | null { function getActiveParentSession(instanceId: string): Session | null {

View File

@@ -1,9 +1,7 @@
@import "./messaging/message-base.css"; @import "./messaging/message-base.css";
@import "./messaging/prompt-input.css"; @import "./messaging/prompt-input.css";
@import "./messaging/message-section.css"; @import "./messaging/message-section.css";
@import "./messaging/virtual-follow-list.css"; @import "./messaging/message-block-list.css";
@import "./messaging/message-selection.css";
@import "./messaging/delete-overlays.css";
@import "./messaging/message-timeline.css"; @import "./messaging/message-timeline.css";
@import "./messaging/tool-call.css"; @import "./messaging/tool-call.css";
@import "./messaging/log-view.css"; @import "./messaging/log-view.css";
@@ -112,3 +110,4 @@
.reasoning-label { .reasoning-label {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }

View File

@@ -1,35 +0,0 @@
/* 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;
}

View File

@@ -8,8 +8,7 @@
} }
.message-item-header { .message-item-header {
@apply flex flex-col; @apply flex flex-col gap-0.5;
gap: 0.25rem;
} }
.message-item-header-row { .message-item-header-row {
@@ -20,58 +19,12 @@
@apply flex justify-between items-start gap-2.5; @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 { .message-item-header-row--bottom {
@apply flex items-start; @apply flex items-start;
} }
.message-speaker { .message-speaker {
/* Allow agent meta to wrap to a second row with comfortable spacing. */ @apply flex flex-col gap-0.5 text-xs;
@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 { .message-speaker-label {
@@ -93,19 +46,19 @@
.message-item-actions { .message-item-actions {
@apply flex items-center gap-2; @apply flex items-center gap-2;
flex: 0 0 auto;
} }
.message-action-group { .message-action-group {
@apply flex items-center gap-0; @apply flex items-center gap-2;
} }
.message-action-button { .message-action-button {
@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; @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;
} }
.message-action-button:hover { .message-action-button:hover {
background-color: var(--surface-hover); background-color: var(--surface-hover);
border-color: var(--accent-primary);
color: var(--accent-primary); color: var(--accent-primary);
} }
@@ -203,27 +156,6 @@
border-top: 1px solid var(--border-base); border-top: 1px solid var(--border-base);
} }
/* Image attachment preview popover.
Rendered via a Portal to avoid being clipped by the message stream scroller. */
.attachment-image-popover {
position: fixed;
padding: 8px;
background-color: var(--surface-base);
border: 1px solid var(--border-base);
border-radius: 10px;
box-shadow: var(--popover-shadow);
z-index: 1000;
pointer-events: none;
}
.attachment-image-popover img {
display: block;
max-width: 320px;
max-height: 320px;
border-radius: 8px;
object-fit: contain;
}
.message-error { .message-error {
@apply text-xs mt-1; @apply text-xs mt-1;
color: var(--status-error); color: var(--status-error);
@@ -364,12 +296,6 @@
color: var(--message-assistant-border); 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 { .message-step-reason {
@apply text-[11px] font-medium; @apply text-[11px] font-medium;
@@ -394,7 +320,7 @@
.message-reasoning-header { .message-reasoning-header {
display: flex; display: flex;
align-items: flex-start; align-items: stretch;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition: background-color 0.2s ease, box-shadow 0.2s ease;
@@ -439,36 +365,11 @@
} }
.message-reasoning-label { .message-reasoning-label {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--message-assistant-border); 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 { .message-reasoning-meta {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -2,16 +2,12 @@
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5; @apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
} }
.message-stream-block { .message-stream-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.0625rem; gap: 0.0625rem;
contain: layout paint style;
} }
.virtual-item-wrapper { .virtual-item-wrapper {

View File

@@ -132,15 +132,13 @@
/* Make the command palette trigger stand out in the header. */ /* Make the command palette trigger stand out in the header. */
.connection-status-button.command-palette-button { .connection-status-button.command-palette-button {
border-radius: 0; border-color: var(--accent-primary);
@apply text-sm px-2 py-1 border border-base transition-colors; background-color: var(--surface-secondary);
background-color: var(--surface-base);
color: var(--text-primary);
} }
.connection-status-button.command-palette-button:hover { .connection-status-button.command-palette-button:hover {
border-color: var(--accent-primary);
background-color: var(--surface-hover); background-color: var(--surface-hover);
color: var(--text-primary);
} }
.connection-status-button:hover { .connection-status-button:hover {

View File

@@ -1,232 +0,0 @@
/* 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;
left: 50%;
transform: translateX(-50%);
bottom: 1rem;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 6px 10px;
/* Match other popups (dropdown-surface / panels) */
background-color: var(--surface-base);
border: 1px solid var(--border-base);
border-radius: 12px;
z-index: 50;
box-shadow: var(--panel-shadow-strong);
width: max-content;
max-width: min(80vw, 560px);
}
.message-delete-mode-toolbar-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.message-delete-mode-token-group {
display: inline-flex;
align-items: center;
gap: 3px;
}
.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: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-primary);
background: color-mix(in oklab, var(--surface-base) 85%, var(--accent-primary));
border: 1px solid color-mix(in oklab, var(--accent-primary) 50%, transparent);
}
.message-delete-mode-count--before {
color: var(--text-muted);
background: color-mix(in oklab, var(--surface-base) 90%, var(--text-muted));
border-color: color-mix(in oklab, var(--text-muted) 30%, transparent);
}
.message-delete-mode-count--selection {
color: var(--status-error);
background: color-mix(in oklab, var(--surface-base) 85%, var(--status-error));
border-color: color-mix(in oklab, var(--status-error) 40%, transparent);
}
.message-delete-mode-count--after {
color: var(--status-success);
background: color-mix(in oklab, var(--surface-base) 85%, var(--status-success));
border-color: color-mix(in oklab, var(--status-success) 40%, transparent);
}
.message-delete-mode-arrow {
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
user-select: none;
}
.message-delete-mode-button {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid color-mix(in oklab, var(--accent-primary) 30%, transparent);
border-radius: 10px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.message-delete-mode-button:hover {
background-color: color-mix(in oklab, var(--accent-primary) 15%, transparent);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.message-delete-mode-button--delete {
color: var(--status-error);
border-color: color-mix(in oklab, var(--status-error) 30%, transparent);
}
.message-delete-mode-button--delete:hover {
background-color: var(--status-error-bg);
border-color: var(--status-error);
color: var(--status-error);
}
.message-delete-mode-button--cancel:hover {
background-color: color-mix(in oklab, var(--text-muted) 12%, transparent);
border-color: var(--text-muted);
color: var(--text-primary);
}
.message-delete-mode-button--menu {
color: var(--text-muted);
}
.message-delete-mode-button--menu:hover {
color: var(--text-primary);
}
.message-delete-mode-menu-container {
position: relative;
}
.message-delete-mode-menu {
right: 0;
bottom: calc(100% + 6px);
min-width: 150px;
width: max-content;
max-width: min(70vw, 220px);
padding: 2px 0;
}
.message-delete-mode-menu-divider {
height: 1px;
margin: 3px 0;
background-color: var(--border-base);
}
.message-delete-mode-menu-row {
display: flex;
flex-direction: column;
gap: 3px;
padding: 2px 8px 6px;
}
.message-delete-mode-menu-label {
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
.message-delete-mode-menu-toggle {
display: inline-flex;
border-radius: 999px;
border: 1px solid var(--border-base);
background-color: var(--surface-secondary);
overflow: hidden;
width: 100%;
}
.message-delete-mode-menu-toggle-button {
padding: 2px 8px;
font-size: 12px;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.message-delete-mode-menu-toggle-button[data-mode="all"] {
flex: 0 0 auto;
min-width: 56px;
}
.message-delete-mode-menu-toggle-button[data-mode="tools"] {
flex: 1 1 auto;
}
.message-delete-mode-menu-toggle-button[data-active="true"] {
color: var(--text-primary);
background-color: color-mix(in oklab, var(--accent-primary) 18%, transparent);
}
.message-delete-mode-menu .dropdown-item {
width: calc(100% - 8px);
display: flex;
align-items: center;
justify-content: center;
margin: 0 4px;
padding: 3px 8px;
font-size: 12px;
border-radius: 6px;
}
.message-delete-mode-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 45%, transparent);
}
.message-delete-mode-hint-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding-top: 2px;
font-size: 10px;
color: var(--text-muted);
user-select: none;
flex-wrap: wrap;
}
.message-delete-mode-hint-text {
white-space: normal;
}
.message-delete-mode-hint-sep {
color: var(--text-muted);
user-select: none;
}

View File

@@ -6,9 +6,6 @@
min-height: 0; min-height: 0;
flex: 1 1 auto; flex: 1 1 auto;
position: relative; position: relative;
/* Isolate stacking context so sidebar z-indices don't compete with
Portals (Command Palette, modals) that live at the body level. */
isolation: isolate;
} }
.message-layout--with-timeline { .message-layout--with-timeline {
@@ -54,8 +51,6 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
z-index: 100;
} }
@@ -72,16 +67,11 @@
gap: 0.35rem; gap: 0.35rem;
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto; overflow-y: auto;
overflow-x: visible;
border-radius: 8px; border-radius: 8px;
background-color: var(--surface-base); background-color: var(--surface-base);
box-shadow: var(--panel-shadow); box-shadow: var(--panel-shadow);
} }
.message-timeline--selection-active {
padding-bottom: 4rem;
}
.message-timeline::-webkit-scrollbar { .message-timeline::-webkit-scrollbar {
width: 5px; width: 5px;
} }
@@ -97,7 +87,6 @@
border-radius: 0; border-radius: 0;
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -107,40 +96,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--text-primary); color: var(--text-primary);
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-touch-callout: none;
}
.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 { .message-timeline-segment.message-timeline-segment-hidden {
@@ -274,162 +229,3 @@
.message-preview .message-item-base { .message-preview .message-item-base {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* --- Selection & Histogram Ribs --- */
.message-timeline-segment-selected {
border-color: var(--accent-primary) !important;
background-color: color-mix(in oklab, var(--accent-primary) 25%, var(--surface-base)) !important;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 50%, transparent) inset !important;
color: var(--accent-primary) !important;
}
/* When a whole message is selected for deletion (via stream checkbox),
reflect that on all timeline segments for that message. */
.message-timeline-segment-delete-selected {
border-color: color-mix(in oklab, var(--status-error) 55%, transparent) !important;
background-color: color-mix(in oklab, var(--status-error) 18%, var(--surface-base)) !important;
box-shadow: 0 0 0 2px color-mix(in oklab, var(--status-error) 35%, transparent) inset !important;
color: var(--status-error) !important;
}
.message-timeline-segment-delete-selected:hover,
.message-timeline-segment-delete-selected:focus-visible {
background-color: color-mix(in oklab, var(--status-error) 24%, var(--surface-base)) !important;
color: var(--status-error) !important;
transform: none;
}
.message-timeline-segment-selected:hover,
.message-timeline-segment-selected:focus-visible {
background-color: color-mix(in oklab, var(--accent-primary) 35%, var(--surface-base)) !important;
color: var(--accent-primary) !important;
transform: none;
}
/* --- Group indicators: tools belong to the same message as their assistant --- */
/* Tool segments that are part of a group get a left accent border. */
.message-timeline-group-child {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
}
/* The assistant "parent" at the bottom of a tool group gets the same border. */
.message-timeline-group-parent {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
}
/* Extra spacing before the first tool in a group to separate from the
preceding user/assistant badge. */
.message-timeline-group-start {
margin-top: 0.35rem;
}
/* Subtle extra spacing after the group parent (assistant) to separate
from the next user badge below. Uses adjacent sibling targeting. */
.message-timeline-group-parent + .message-timeline-user,
.message-timeline-group-parent + .message-timeline-compaction {
margin-top: 0.35rem;
}
.message-timeline-container {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.message-timeline-xray-overlay {
position: absolute;
inset: 0;
/* Extend the overlay box into the stream so ribs are not relying on
overflow-visible behavior (which is brittle around scroll containers). */
--xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
left: calc(-1 * var(--xray-overhang));
width: calc(100% + var(--xray-overhang));
overflow: hidden;
padding: 0.25rem;
pointer-events: none;
/* Above the scroll container background; still non-interactive. */
z-index: 2;
--xray-scroll-y: 0px;
}
.message-timeline-xray-overlay-inner {
position: absolute;
inset: 0;
transform: translateY(var(--xray-scroll-y));
will-change: transform;
}
.message-timeline-xray-rib {
position: absolute;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
transform: translate(-100%, -50%);
}
.message-timeline-xray-token-label {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 4px;
height: 1.5rem;
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--text-primary);
background: var(--surface-base);
padding: 1px 5px;
border: 1px solid var(--border-base);
border-radius: 999px;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.message-timeline-relative-bar {
height: 5px;
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
background-color: color-mix(
in srgb,
var(--status-success) calc(100% - var(--segment-weight) * 100%),
var(--status-error) calc(var(--segment-weight) * 100%)
);
border-radius: 3px 0 0 3px;
transition: width 0.3s ease, background-color 0.3s ease;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.25);
}
.message-timeline-absolute-bar {
height: 3px;
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
background-color: var(--text-muted);
border-radius: 2px 0 0 2px;
transition: width 0.3s ease;
opacity: 0.5;
position: relative;
}
.message-timeline-absolute-bar-overflow {
opacity: 0.8;
}
.message-timeline-absolute-bar-overflow::before {
content: "";
position: absolute;
left: -1px;
top: -3px;
bottom: -3px;
width: 3px;
border-radius: 2px;
background: var(--status-error);
box-shadow: 0 0 6px 2px var(--status-error);
}

View File

@@ -21,9 +21,9 @@
.tool-call-header-button { .tool-call-header-button {
background-color: transparent; background-color: transparent;
border: 1px solid var(--tool-call-border-color, var(--border-base));
color: var(--text-secondary); color: var(--text-secondary);
border: 0; padding: 0.15rem 0.75rem;
padding: 0.1rem 0.35rem;
border-radius: 0.375rem; border-radius: 0.375rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
@@ -37,6 +37,7 @@
.tool-call-header-button:hover:not(:disabled) { .tool-call-header-button:hover:not(:disabled) {
background-color: var(--surface-hover); background-color: var(--surface-hover);
border-color: var(--accent-primary);
color: var(--accent-primary); color: var(--accent-primary);
} }

View File

@@ -412,7 +412,7 @@
} }
.right-panel-accordion-trigger { .right-panel-accordion-trigger {
@apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150; @apply w-full flex items-center justify-between gap-3 px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150;
color: var(--text-secondary); color: var(--text-secondary);
background-color: transparent; background-color: transparent;
} }
@@ -422,11 +422,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.section-left {
@apply flex items-center;
flex-shrink: 0;
}
.right-panel-accordion-chevron { .right-panel-accordion-chevron {
@apply h-4 w-4 transition-transform duration-200; @apply h-4 w-4 transition-transform duration-200;
color: var(--text-muted); color: var(--text-muted);
@@ -446,51 +441,6 @@
min-height: 0; min-height: 0;
} }
/* Section info tooltip */
.section-info-trigger {
@apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150;
color: var(--text-muted);
flex-shrink: 0;
}
.section-info-trigger:hover {
color: var(--text-primary);
background-color: var(--surface-hover);
}
.section-label {
margin-left: 2px;
}
.section-info-icon {
@apply w-3.5 h-3.5;
}
.section-info-tooltip {
@apply max-w-xs px-3 py-2 text-xs rounded-lg border shadow-lg;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
animation: tooltipShow 150ms ease-out;
transform-origin: var(--kb-tooltip-content-transform-origin);
z-index: 9999;
}
.section-info-tooltip[data-expanded] {
animation: tooltipShow 150ms ease-out;
}
@keyframes tooltipShow {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Background process cards in status panel */ /* Background process cards in status panel */
.status-process-card { .status-process-card {
@apply rounded-lg border flex flex-col gap-2 p-3 transition-all duration-150; @apply rounded-lg border flex flex-col gap-2 p-3 transition-all duration-150;

View File

@@ -1,4 +0,0 @@
export type DeleteHoverState =
| { kind: "none" }
| { kind: "message"; messageId: string }
| { kind: "deleteUpTo"; messageId: string }

View File

@@ -1,4 +1,4 @@
// SDK v2 types // SDK types
import type { import type {
EventMessageUpdated as MessageUpdateEvent, EventMessageUpdated as MessageUpdateEvent,
EventMessageRemoved as MessageRemovedEvent, EventMessageRemoved as MessageRemovedEvent,
@@ -6,8 +6,7 @@ import type {
EventMessagePartRemoved as MessagePartRemovedEvent, EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart, Part as SDKPart,
Message as SDKMessage, Message as SDKMessage,
AssistantMessage as SDKAssistantMessageV2, } from "@opencode-ai/sdk"
} from "@opencode-ai/sdk/v2"
import type { PermissionRequestLike } from "./permission" import type { PermissionRequestLike } from "./permission"
@@ -18,8 +17,7 @@ export type {
MessagePartUpdatedEvent, MessagePartUpdatedEvent,
MessagePartRemovedEvent, MessagePartRemovedEvent,
SDKPart, SDKPart,
SDKMessage, SDKMessage
SDKAssistantMessageV2,
} }
// Server streaming event: append-only delta updates. // Server streaming event: append-only delta updates.