Compare commits
16 Commits
v0.12.1
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5695a903 | ||
|
|
77103b7292 | ||
|
|
b14a144ddc | ||
|
|
8ac67311d8 | ||
|
|
0c97db393c | ||
|
|
614c300d2f | ||
|
|
e6ca4bd43d | ||
|
|
84f81cf829 | ||
|
|
3760ba2d7f | ||
|
|
09e7a3f8da | ||
|
|
c55d56c94b | ||
|
|
cc53123bcd | ||
|
|
d64027d43d | ||
|
|
6b7162f50f | ||
|
|
5fd985f0c2 | ||
|
|
2a438b2bb3 |
145
.github/workflows/build-and-upload.yml
vendored
145
.github/workflows/build-and-upload.yml
vendored
@@ -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
|
||||||
|
|||||||
61
.github/workflows/dev-release.yml
vendored
61
.github/workflows/dev-release.yml
vendored
@@ -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
|
||||||
|
|||||||
6
.github/workflows/manual-npm-publish.yml
vendored
6
.github/workflows/manual-npm-publish.yml
vendored
@@ -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
|
||||||
|
|||||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -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
|
||||||
|
|||||||
11
.github/workflows/reusable-release.yml
vendored
11
.github/workflows/reusable-release.yml
vendored
@@ -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
13
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.)
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
64
packages/ui/src/components/message-block-list.tsx
Normal file
64
packages/ui/src/components/message-block-list.tsx
Normal 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" }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" }>
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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é",
|
||||||
|
|||||||
@@ -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": "変更を取得中...",
|
||||||
|
|||||||
@@ -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": "削除に失敗しました",
|
||||||
|
|||||||
@@ -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": "貼り付けたテキストを展開",
|
||||||
|
|||||||
@@ -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": "Добавить",
|
||||||
|
|||||||
@@ -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": "Ошибка удаления",
|
||||||
|
|||||||
@@ -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": "Развернуть вставленный текст",
|
||||||
|
|||||||
@@ -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": "正在获取会话更改...",
|
||||||
|
|||||||
@@ -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": "删除失败",
|
||||||
|
|||||||
@@ -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": "展开粘贴的文本",
|
||||||
|
|||||||
@@ -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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export type DeleteHoverState =
|
|
||||||
| { kind: "none" }
|
|
||||||
| { kind: "message"; messageId: string }
|
|
||||||
| { kind: "deleteUpTo"; messageId: string }
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user