Compare commits

..

1 Commits

Author SHA1 Message Date
Shantur Rathore
70f692c5df Fix inline markdown code text color 2026-02-12 19:49:26 +00:00
190 changed files with 2977 additions and 9842 deletions

View File

@@ -3,11 +3,6 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -50,8 +45,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -61,21 +54,7 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
shell: bash run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
run: |
set -euo pipefail
for attempt in 1 2 3; do
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
exit 0
fi
echo "npm version failed (attempt $attempt/3); retrying..." >&2
sleep $((attempt * 10))
done
exit 1
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@@ -86,112 +65,6 @@ jobs:
- name: Build macOS binaries (Electron) - name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
shell: bash
run: |
set -euo pipefail
release_root="packages/electron-app/release"
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
# GitHub macOS runners typically have no signing identity. Without any signature,
# the shipped .app can fail Gatekeeper with:
# code has no resources but signature indicates they must be present
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
for app in "${apps[@]}"; do
echo "codesign (adhoc): $app"
codesign --force --deep --sign - "$app"
codesign --verify --deep --strict --verbose=2 "$app"
done
else
echo "macOS codesigning identity present; skipping ad-hoc signing"
fi
- name: Repackage Electron macOS zips (ditto)
shell: bash
run: |
set -euo pipefail
# Prefer the workflow-provided version; fall back to package.json.
VERSION_TO_USE="${VERSION:-}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
fi
release_root="packages/electron-app/release"
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
# Use find to locate built app bundles instead of ** globs.
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
for app in "${apps[@]}"; do
bundle_dir=$(basename "$(dirname "$app")")
arch="x64"
if [[ "$bundle_dir" == *"arm64"* ]]; then
arch="arm64"
fi
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
rm -f "$out_zip"
echo "ditto -ck: $app -> $out_zip"
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
done
- name: Validate Electron macOS codesign (unzipped)
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
if [ "${#zips[@]}" -eq 0 ]; then
echo "No Electron macOS zip artifacts found to validate" >&2
exit 1
fi
for zip in "${zips[@]}"; do
echo "Validating codesign for: $zip"
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
mkdir -p "$extract_dir"
# Use ditto for extraction as well to preserve bundle metadata.
ditto -x -k "$zip" "$extract_dir"
app_path=""
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
if [ -d "$candidate" ]; then
app_path="$candidate"
break
fi
done
if [ -z "$app_path" ]; then
echo "No .app found after extracting $zip" >&2
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$app_path"
done
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -212,8 +85,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -253,8 +124,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -295,8 +164,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -370,8 +237,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -445,8 +310,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -525,8 +388,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -629,8 +490,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -728,8 +587,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,80 +1,18 @@
name: Develop Pre-Release name: Dev CI
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 contents: read
id-token: write
contents: write
concurrency:
group: dev-prerelease
cancel-in-progress: true
jobs: jobs:
gate: dev-ci:
runs-on: ubuntu-latest uses: ./.github/workflows/build-and-upload.yml
outputs:
run: ${{ steps.gate.outputs.run }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps:
- name: Decide whether to run
id: gate
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
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)
SHA8="${DEV_SHA::8}"
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:
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml
with: with:
ref: ${{ needs.gate.outputs.dev_sha }} upload: false
version_suffix: ${{ needs.gate.outputs.version_suffix }} set_versions: false
npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest
prerelease: true
release_ui: false
secrets: inherit secrets: inherit

View File

@@ -12,17 +12,8 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
package_name:
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
required: false
default: "@neuralnomads/codenomad"
type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -30,13 +21,6 @@ on:
required: false required: false
type: string type: string
default: dev default: dev
package_name:
required: false
type: string
default: "@neuralnomads/codenomad"
secrets:
NPM_TOKEN:
required: false
permissions: permissions:
contents: read contents: read
@@ -50,8 +34,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
@@ -69,7 +51,7 @@ jobs:
run: npm install @rollup/rollup-linux-x64-gnu --no-save run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling) - name: Build server package (includes UI bundling)
run: npm run build --workspace packages/server run: npm run build --workspace @neuralnomads/codenomad
- name: Set publish metadata - name: Set publish metadata
shell: bash shell: bash
@@ -80,31 +62,13 @@ jobs:
fi fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV" echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV" echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
- name: Bump package version for publish - name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Set server package name for publish
shell: bash
run: |
set -euo pipefail
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
- name: Publish server package with provenance - name: Publish server package with provenance
env: env:
# Optional: when present, npm will use token auth.
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
NPM_CONFIG_REGISTRY: https://registry.npmjs.org NPM_CONFIG_REGISTRY: https://registry.npmjs.org
shell: bash
run: | run: |
set -euo pipefail npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
unset NODE_AUTH_TOKEN
else
echo "Using NPM_TOKEN authentication"
fi
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance

View File

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

View File

@@ -14,5 +14,4 @@ jobs:
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
dist_tag: latest dist_tag: latest
npm_package_name: "@neuralnomads/codenomad"
secrets: inherit secrets: inherit

View File

@@ -3,11 +3,6 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -18,21 +13,6 @@ on:
required: false required: false
default: dev default: dev
type: string type: string
npm_package_name:
description: "npm package name to publish (defaults to server package name)"
required: false
default: ""
type: string
prerelease:
description: "Create GitHub prerelease"
required: false
default: false
type: boolean
release_ui:
description: "Publish remote UI + manifest"
required: false
default: true
type: boolean
permissions: permissions:
id-token: write id-token: write
@@ -51,8 +31,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
@@ -75,23 +53,17 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }} TAG: ${{ steps.versions.outputs.tag }}
IS_PRERELEASE: ${{ inputs.prerelease }}
run: | run: |
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists" echo "Release $TAG already exists"
else else
if [ "${IS_PRERELEASE}" = "true" ]; then gh release create "$TAG" --title "$TAG" --generate-notes
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
fi fi
build-and-upload: build-and-upload:
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 }}
@@ -99,12 +71,9 @@ jobs:
release-ui: release-ui:
needs: prepare-release needs: prepare-release
if: ${{ inputs.release_ui }}
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,8 +82,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 }}
secrets: inherit secrets: inherit

View File

@@ -44,21 +44,13 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
Full server/CLI documentation (flags + env vars, TLS, auth, remote access): For dev version
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash ```bash
npx @neuralnomads/codenomad --help npx @neuralnomads/codenomad@dev --launch
``` ```
### 🧪 Dev Releases This command starts the server and opens the web client in your default browser.
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
## Highlights ## Highlights
@@ -123,6 +115,3 @@ To build the Desktop App from source:
1. Clone the repo. 1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces). 2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. 3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)

43
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.2", "version": "0.10.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.2", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -2809,9 +2809,9 @@
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.2.6", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==", "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
@@ -11879,21 +11879,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"dev": true, "dev": true,
@@ -11985,17 +11970,15 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server", "@neuralnomads/codenomad": "file:../server"
"yaml": "^2.4.2"
}, },
"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 +12005,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.2", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12034,7 +12017,6 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@@ -12063,7 +12045,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.12.2", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12071,12 +12053,12 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.2", "version": "0.10.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -12093,8 +12075,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0", "tauri-plugin-keepawake-api": "^0.1.0"
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
@@ -40,36 +39,6 @@ interface CliEntryResolution {
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
const resolved = resolveConfigPath(target)
if (isYamlPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
}
if (isJsonPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
}
// Treat as directory.
return {
configYamlPath: path.join(resolved, "config.yaml"),
legacyJsonPath: path.join(resolved, "config.json"),
}
}
function resolveConfigPath(configPath?: string): string { function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) { if (target.startsWith("~/")) {
@@ -84,20 +53,11 @@ function resolveHostForMode(mode: ListeningMode): string {
function readListeningModeFromConfig(): ListeningMode { function readListeningModeFromConfig(): ListeningMode {
try { try {
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
let parsed: any = null const content = readFileSync(configPath, "utf-8")
if (existsSync(configYamlPath)) { const parsed = JSON.parse(content)
const content = readFileSync(configYamlPath, "utf-8") const mode = parsed?.preferences?.listeningMode
parsed = parseYaml(content)
} else if (existsSync(legacyJsonPath)) {
const content = readFileSync(legacyJsonPath, "utf-8")
parsed = JSON.parse(content)
} else {
return "local"
}
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") { if (mode === "local" || mode === "all") {
return mode return mode
} }
@@ -431,9 +391,7 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) { if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim() args.push("--ui-dev-server", devServer, "--log-level", "debug")
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
} }
return args return args

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2", "version": "0.10.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",
@@ -39,13 +36,11 @@
}, },
"dependencies": { "dependencies": {
"@neuralnomads/codenomad": "file:../server", "@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui"
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

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

View File

@@ -1,4 +1 @@
public/ public/
# Local developer config (may contain secrets)
config-*.json

View File

@@ -5,21 +5,18 @@
## Features & Capabilities ## Features & Capabilities
### 🌍 Deployment Freedom ### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. - **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. - **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. - **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect. - **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power ### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches. - **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts. - **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites ## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system. - **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source). - Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve. - A workspace folder on disk you want to serve.
@@ -28,26 +25,18 @@
## 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
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs: On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells) - `Local Connection URL : ...` (used by desktop shells)
- `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
@@ -55,19 +44,7 @@ npm install -g @neuralnomads/codenomad
codenomad --launch codenomad --launch
``` ```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`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,36 +58,15 @@ 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 |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | | `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | | `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth | | `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
| `--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-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh
npx @neuralnomads/codenomad-dev --launch
```
These environment variables control how CodeNomad checks for dev updates:
| Env Variable | Description |
|-------------|-------------|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
### HTTP vs HTTPS ### HTTP vs HTTPS
@@ -149,14 +105,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 +122,5 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). > If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.2", "version": "0.10.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -34,7 +34,6 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -1,6 +1,7 @@
import type { import type {
AgentModelSelection, AgentModelSelection,
AgentModelSelections, AgentModelSelections,
ConfigFile,
ModelPreference, ModelPreference,
OpenCodeBinary, OpenCodeBinary,
Preferences, Preferences,
@@ -182,9 +183,9 @@ export interface BinaryRecord {
validationError?: string validationError?: string
} }
export type SettingsOwner = string export type AppConfig = ConfigFile
export type SettingsBucket = Record<string, unknown> export type AppConfigResponse = AppConfig
export type SettingsDoc = Record<string, unknown> export type AppConfigUpdateRequest = Partial<AppConfig>
export interface BinaryListResponse { export interface BinaryListResponse {
binaries: BinaryRecord[] binaries: BinaryRecord[]
@@ -213,8 +214,8 @@ export type WorkspaceEventType =
| "workspace.error" | "workspace.error"
| "workspace.stopped" | "workspace.stopped"
| "workspace.log" | "workspace.log"
| "storage.configChanged" | "config.appChanged"
| "storage.stateChanged" | "config.binariesChanged"
| "instance.dataChanged" | "instance.dataChanged"
| "instance.event" | "instance.event"
| "instance.eventStatus" | "instance.eventStatus"
@@ -225,8 +226,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "config.appChanged"; config: AppConfig }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
@@ -285,8 +286,6 @@ export interface ServerMeta {
serverVersion?: string serverVersion?: string
ui?: UiMeta ui?: UiMeta
support?: SupportMeta support?: SupportMeta
/** Optional update info (dev channel only). */
update?: LatestReleaseInfo | null
} }
export type BackgroundProcessStatus = "running" | "stopped" | "error" export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -0,0 +1,192 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
private readonly configStore: ConfigStore,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
this.logger.warn("No configured binaries found, falling back to opencode")
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.replace(nextConfig)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
if (updates.makeDefault) {
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.replace(nextConfig)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.replace(nextConfig)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
this.logger.debug({ path }, "Validating OpenCode binary path")
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -1,78 +0,0 @@
import os from "os"
import path from "path"
export interface ConfigLocation {
/** Resolved absolute base directory containing all persisted server data. */
baseDir: string
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
configYamlPath: string
/** Canonical YAML state file path (always in baseDir). */
stateYamlPath: string
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
legacyJsonPath: string
/** Directory for per-instance persisted data (chat history etc.). */
instancesDir: string
}
function resolvePath(inputPath: string): string {
if (inputPath.startsWith("~/")) {
return path.join(os.homedir(), inputPath.slice(2))
}
return path.resolve(inputPath)
}
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
/**
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
*
* Supported inputs:
* - Directory: "~/.config/codenomad"
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
* - Legacy JSON file: "~/.config/codenomad/config.json"
*/
export function resolveConfigLocation(raw: string): ConfigLocation {
const trimmed = (raw ?? "").trim()
const fallback = "~/.config/codenomad/config.json"
const input = trimmed.length > 0 ? trimmed : fallback
const resolvedInput = resolvePath(input)
if (isYamlPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: resolvedInput,
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}
if (isJsonPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: resolvedInput,
instancesDir: path.join(baseDir, "instances"),
}
}
const baseDir = resolvedInput
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}

View File

@@ -8,8 +8,7 @@ const ModelPreferenceSchema = z.object({
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z const PreferencesSchema = z.object({
.object({
showThinkingBlocks: z.boolean().default(false), showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true), showTimelineTools: z.boolean().default(true),
@@ -32,9 +31,7 @@ const PreferencesSchema = z
osNotificationsAllowWhenVisible: z.boolean().default(false), osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true), notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true), notifyOnIdle: z.boolean().default(true),
}) })
// Preserve unknown preference keys so newer configs survive older binaries.
.passthrough()
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({
path: z.string(), path: z.string(),
@@ -48,35 +45,14 @@ const OpenCodeBinarySchema = z.object({
label: z.string().optional(), label: z.string().optional(),
}) })
const ConfigFileSchema = z const ConfigFileSchema = z.object({
.object({ preferences: PreferencesSchema.default({}),
preferences: PreferencesSchema.default({}), recentFolders: z.array(RecentFolderSchema).default([]),
recentFolders: z.array(RecentFolderSchema).default([]), opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), theme: z.enum(["light", "dark", "system"]).optional(),
theme: z.enum(["light", "dark", "system"]).optional(), })
})
// Preserve unknown top-level keys so optional future features survive downgrades.
.passthrough()
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
const ConfigYamlSchema = z
.object({
preferences: PreferencesSchema.default({}),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
.passthrough()
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
const StateFileSchema = z
.object({
recentFolders: z.array(RecentFolderSchema).default([]),
})
.passthrough()
const DEFAULT_CONFIG = ConfigFileSchema.parse({}) const DEFAULT_CONFIG = ConfigFileSchema.parse({})
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
const DEFAULT_STATE = StateFileSchema.parse({})
export { export {
ModelPreferenceSchema, ModelPreferenceSchema,
@@ -86,11 +62,7 @@ export {
RecentFolderSchema, RecentFolderSchema,
OpenCodeBinarySchema, OpenCodeBinarySchema,
ConfigFileSchema, ConfigFileSchema,
ConfigYamlSchema,
StateFileSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
} }
export type ModelPreference = z.infer<typeof ModelPreferenceSchema> export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
@@ -100,5 +72,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema> export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema> export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema> export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
export type StateFile = z.infer<typeof StateFileSchema>

View File

@@ -0,0 +1,78 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private loaded = false
constructor(
private readonly configPath: string,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const resolved = this.resolvePath(this.configPath)
if (fs.existsSync(resolved)) {
const content = fs.readFileSync(resolved, "utf-8")
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed)
this.logger.debug({ resolved }, "Loaded existing config file")
} else {
this.cache = DEFAULT_CONFIG
this.logger.debug({ resolved }, "No config file found, using defaults")
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults")
this.cache = DEFAULT_CONFIG
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}

View File

@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler) this.on("workspace.error", handler)
this.on("workspace.stopped", handler) this.on("workspace.stopped", handler)
this.on("workspace.log", handler) this.on("workspace.log", handler)
this.on("storage.configChanged", handler) this.on("config.appChanged", handler)
this.on("storage.stateChanged", handler) this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler) this.on("instance.dataChanged", handler)
this.on("instance.event", handler) this.on("instance.event", handler)
this.on("instance.eventStatus", handler) this.on("instance.eventStatus", handler)
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler) this.off("workspace.error", handler)
this.off("workspace.stopped", handler) this.off("workspace.stopped", handler)
this.off("workspace.log", handler) this.off("workspace.log", handler)
this.off("storage.configChanged", handler) this.off("config.appChanged", handler)
this.off("storage.stateChanged", handler) this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)
this.off("instance.event", handler) this.off("instance.event", handler)
this.off("instance.eventStatus", handler) this.off("instance.eventStatus", handler)

View File

@@ -8,9 +8,8 @@ import { fileURLToPath } from "url"
import { createRequire } from "module" import { createRequire } from "module"
import { createHttpServer } from "./server/http-server" import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager" import { WorkspaceManager } from "./workspaces/manager"
import { resolveConfigLocation } from "./config/location" import { ConfigStore } from "./config/store"
import { SettingsService } from "./settings/service" import { BinaryRegistry } from "./config/binaries"
import { BinaryResolver } from "./settings/binaries"
import { FileSystemBrowser } from "./filesystem/browser" import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types" import { ServerMeta } from "./api-types"
@@ -22,7 +21,6 @@ import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses" import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -78,7 +76,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))
@@ -212,6 +210,13 @@ function resolveHost(input: string | undefined): string {
return trimmed return trimmed
} }
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean { function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag) return argv.includes(flag)
} }
@@ -240,8 +245,7 @@ async function main() {
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configLocation = resolveConfigLocation(options.configPath) const configDir = path.dirname(resolvePath(options.configPath))
const configDir = configLocation.baseDir
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
@@ -262,7 +266,7 @@ async function main() {
const authManager = new AuthManager( const authManager = new AuthManager(
{ {
configPath: configLocation.configYamlPath, configPath: options.configPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
generateToken: options.generateToken, generateToken: options.generateToken,
@@ -291,19 +295,19 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const settings = new SettingsService(configLocation, eventBus, configLogger) const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryResolver = new BinaryResolver(settings) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir, rootDir: options.rootDir,
settings, configStore,
binaryResolver, binaryRegistry,
eventBus, eventBus,
logger: workspaceLogger, logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.localUrl, getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath, nodeExtraCaCertsPath,
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -340,21 +344,6 @@ async function main() {
minServerVersion: uiResolution.minServerVersion, minServerVersion: uiResolution.minServerVersion,
} }
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
const devReleaseMonitor = enableDevUpdateChecks
? startDevReleaseMonitor({
currentVersion: packageJson.version,
repo: githubRepo,
logger: logger.child({ component: "updates" }),
onUpdate: (release) => {
serverMeta.update = release
},
})
: null
if (uiResolution.uiDevServerUrl && options.https) { if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
} }
@@ -383,7 +372,8 @@ async function main() {
defaultPort: options.httpPort, defaultPort: options.httpPort,
protocol: "http", protocol: "http",
workspaceManager, workspaceManager,
settings, configStore,
binaryRegistry,
fileSystemBrowser, fileSystemBrowser,
eventBus, eventBus,
serverMeta, serverMeta,
@@ -403,7 +393,8 @@ async function main() {
protocol: "https", protocol: "https",
httpsOptions: tlsResolution?.httpsOptions, httpsOptions: tlsResolution?.httpsOptions,
workspaceManager, workspaceManager,
settings, configStore,
binaryRegistry,
fileSystemBrowser, fileSystemBrowser,
eventBus, eventBus,
serverMeta, serverMeta,
@@ -512,8 +503,6 @@ async function main() {
// no-op: remote UI manifest replaces GitHub release monitor // no-op: remote UI manifest replaces GitHub release monitor
devReleaseMonitor?.stop()
logger.info("Exiting process") logger.info("Exiting process")
process.exit(0) process.exit(0)
} }

View File

@@ -1,118 +0,0 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
interface DevReleaseMonitorOptions {
/** Current running server version (from package.json). */
currentVersion: string
/** GitHub repo in the form "owner/name". */
repo: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
pollIntervalMs?: number
}
interface GithubReleaseListItem {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
draft?: boolean
}
export interface DevReleaseMonitor {
stop(): void
}
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
let stopped = false
let timer: ReturnType<typeof setInterval> | null = null
const pollIntervalMs =
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
? (options.pollIntervalMs as number)
: DEFAULT_POLL_INTERVAL_MS
const refresh = async () => {
if (stopped) return
try {
const release = await fetchLatestPrerelease({
repo: options.repo,
currentVersion: options.currentVersion,
})
options.onUpdate(release)
} catch (error) {
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
}
}
void refresh()
timer = setInterval(() => void refresh(), pollIntervalMs)
return {
stop() {
stopped = true
if (timer) {
clearInterval(timer)
timer = null
}
},
}
}
async function fetchLatestPrerelease(args: {
repo: string
currentVersion: string
}): Promise<LatestReleaseInfo | null> {
const normalizedRepo = args.repo.trim()
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
throw new Error(`Invalid GitHub repo: ${args.repo}`)
}
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
const response = await fetch(apiUrl, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`GitHub releases API responded with ${response.status}`)
}
const list = (await response.json()) as GithubReleaseListItem[]
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
if (!latest) {
return null
}
const tag = latest.tag_name || latest.name
if (!tag) {
return null
}
const normalizedVersion = stripTagPrefix(tag)
if (!normalizedVersion) {
return null
}
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
return null
}
return {
version: normalizedVersion,
tag,
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
channel: "dev",
publishedAt: latest.published_at ?? latest.created_at,
notes: latest.body,
}
}

View File

@@ -52,12 +52,6 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
} }
} }
export function compareVersionStrings(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
return compareVersions(left, right)
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> { async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, { const response = await fetch(RELEASES_API_URL, {
headers: { headers: {
@@ -98,7 +92,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
} }
} }
export function stripTagPrefix(tag: string | undefined): string | null { function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null if (!tag) return null
const trimmed = tag.trim() const trimmed = tag.trim()
if (!trimmed) return null if (!trimmed) return null
@@ -107,9 +101,7 @@ export function stripTagPrefix(tag: string | undefined): string | null {
function parseVersion(value: string): NormalizedVersion { function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0" const normalized = stripTagPrefix(value) ?? "0.0.0"
const dashIndex = normalized.indexOf("-") const [core, prerelease = null] = normalized.split("-", 2)
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => { const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10) const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0 return Number.isFinite(parsed) ? parsed : 0

View File

@@ -9,11 +9,12 @@ import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import type { SettingsService } from "../settings/service" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser" import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces" import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerSettingsRoutes } from "./routes/settings" import { registerConfigRoutes } from "./routes/config"
import { registerFilesystemRoutes } from "./routes/filesystem" import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta" import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events" import { registerEventRoutes } from "./routes/events"
@@ -36,7 +37,8 @@ interface HttpServerDeps {
protocol: "http" | "https" protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer } httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
settings: SettingsService configStore: ConfigStore
binaryRegistry: BinaryRegistry
fileSystemBrowser: FileSystemBrowser fileSystemBrowser: FileSystemBrowser
eventBus: EventBus eventBus: EventBus
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -242,7 +244,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}) })
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
@@ -367,21 +369,6 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1" const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: { async function proxyWorkspaceRequest(args: {
request: FastifyRequest request: FastifyRequest
reply: FastifyReply reply: FastifyReply
@@ -472,43 +459,19 @@ async function proxyWorkspaceRequest(args: {
return return
} }
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined } const directory = await resolveWorktreeDirectory({
try { workspaceId,
extracted = extractOpencodeDirectoryOverride(args.pathSuffix) workspacePath: workspace.path,
} catch (error) { worktreeSlug,
const message = error instanceof Error ? error.message : "Invalid directory override" logger,
reply.code(400).send({ error: message }) })
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return return
} }
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
if (extracted.overrideDirectory) { const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -572,89 +535,6 @@ async function proxyWorkspaceRequest(args: {
}) })
} }
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) { function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") { if (!pathSuffix || pathSuffix === "/") {
return "/" return "/"

View File

@@ -119,8 +119,7 @@
showError(message || `Login failed (${res.status})`) showError(message || `Login failed (${res.status})`)
return return
} }
// Replace history entry so Back doesn't return to /login. window.location.href = "/"
window.location.replace("/")
} catch (e) { } catch (e) {
showError(e && e.message ? e.message : String(e)) showError(e && e.message ? e.message : String(e))
} }

View File

@@ -51,19 +51,7 @@ function getTokenHtml(): string {
} }
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (request, reply) => { app.get("/login", async (_request, reply) => {
// If already authenticated, don't show the login page.
const session = deps.authManager.getSessionFromRequest(request)
if (session) {
reply.redirect("/")
return
}
// Avoid caching the login page (helps with bfcache/back behavior).
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
const status = deps.authManager.getStatus() const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username)) reply.type("text/html").send(getLoginHtml(status.username))
}) })
@@ -79,11 +67,6 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
return return
} }
// Avoid caching the token bootstrap page.
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(getTokenHtml()) reply.type("text/html").send(getTokenHtml())
}) })

View File

@@ -0,0 +1,62 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps {
configStore: ConfigStore
binaryRegistry: BinaryRegistry
}
const BinaryCreateSchema = z.object({
path: z.string(),
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryUpdateSchema = z.object({
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryValidateSchema = z.object({
path: z.string(),
})
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.replace(body)
return deps.configStore.get()
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -1,80 +0,0 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
interface RouteDeps {
settings: SettingsService
logger: Logger
}
const ValidateBinarySchema = z.object({
path: z.string(),
})
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
const result = probeBinaryVersion(binaryPath)
return { valid: result.valid, version: result.version, error: result.error }
}
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
app.patch("/api/storage/state", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("state", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
return deps.settings.getOwner("state", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
// Binary validation helper (used by UI when adding binaries)
app.post("/api/storage/binaries/validate", async (request, reply) => {
try {
const body = ValidateBinarySchema.parse(request.body ?? {})
return validateBinaryPath(body.path)
} catch (error) {
deps.logger.warn({ err: error }, "Failed to validate binary")
reply.code(400)
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
}
})
}

View File

@@ -1,55 +0,0 @@
import type { SettingsService } from "./service"
export interface OpenCodeBinaryEntry {
path: string
version?: string
lastUsed?: number
label?: string
}
export interface ResolvedBinary {
path: string
label: string
version?: string
}
function prettyLabel(p: string): string {
const parts = p.split(/[\\/]/)
const last = parts[parts.length - 1] || p
return last || p
}
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
const ui = settings.getOwner("state", "ui")
const list = (ui as any)?.opencodeBinaries
if (!Array.isArray(list)) return []
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
}
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
const server = settings.getOwner("config", "server")
const value = (server as any)?.opencodeBinary
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
}
export class BinaryResolver {
constructor(private readonly settings: SettingsService) {}
list(): OpenCodeBinaryEntry[] {
return readUiBinaries(this.settings)
}
resolveDefault(): ResolvedBinary {
const binaries = this.list()
const configuredDefault = readDefaultBinaryPath(this.settings)
const fallback = binaries[0]?.path
const path = configuredDefault ?? fallback ?? "opencode"
const entry = binaries.find((b) => b.path === path)
return {
path,
label: entry?.label ?? prettyLabel(path),
version: entry?.version,
}
}
}

View File

@@ -1,39 +0,0 @@
type PlainObject = Record<string, unknown>
export function isPlainObject(value: unknown): value is PlainObject {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* RFC 7396-ish merge patch with explicit null deletes.
* - Objects merge recursively
* - Arrays/scalars replace
* - null deletes keys
*/
export function applyMergePatch(current: unknown, patch: unknown): unknown {
if (!isPlainObject(patch)) {
return patch
}
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
const existing = base[key]
if (isPlainObject(value) && isPlainObject(existing)) {
base[key] = applyMergePatch(existing, value)
continue
}
base[key] = value
}
return base
}

View File

@@ -1,269 +0,0 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import type { ConfigLocation } from "../config/location"
import { isPlainObject } from "./merge-patch"
type Doc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function safeReadYaml(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return parseYaml(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
return null
}
}
function safeReadJson(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return JSON.parse(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
return null
}
}
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const yaml = stringifyYaml(doc as any)
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
}
}
function pickBackupPath(filePath: string): string {
const preferred = `${filePath}.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${filePath}.bak.${Date.now()}`
}
function normalizeDoc(value: unknown): Doc {
return isPlainObject(value) ? (value as Doc) : {}
}
function looksLikeNewOwnerDoc(value: unknown): boolean {
const doc = normalizeDoc(value)
// Heuristic: owner-bucket docs have at least one of these roots.
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
}
function looksLikeLegacyConfig(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
}
function looksLikeLegacyState(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.recentFolders)
}
function omitKeys(source: Doc, keys: Set<string>): Doc {
const out: Doc = {}
for (const [k, v] of Object.entries(source)) {
if (keys.has(k)) continue
out[k] = v
}
return out
}
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
const cfg = normalizeDoc(legacyConfig)
const st = normalizeDoc(legacyState)
const outConfig: Doc = {}
const outState: Doc = {}
const uiConfig: Doc = {}
const uiSettings: Doc = {}
const serverConfig: Doc = {}
const uiState: Doc = {}
// theme -> config.ui.theme
if (typeof cfg.theme === "string") {
uiConfig.theme = cfg.theme
}
const preferences = normalizeDoc(cfg.preferences)
if (Object.keys(preferences).length > 0) {
// Server-owned stable keys
const envVars = preferences.environmentVariables
if (isPlainObject(envVars)) {
serverConfig.environmentVariables = envVars
}
const listeningMode = preferences.listeningMode
if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode
}
const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary
}
// UI-owned state keys (drop preferences)
const modelRecents = preferences.modelRecents
const modelFavorites = preferences.modelFavorites
const modelThinkingSelections = preferences.modelThinkingSelections
const models: Doc = {}
if (Array.isArray(modelRecents)) {
models.recents = modelRecents
}
if (Array.isArray(modelFavorites)) {
models.favorites = modelFavorites
}
if (isPlainObject(modelThinkingSelections)) {
models.thinkingSelections = modelThinkingSelections
}
if (Object.keys(models).length > 0) {
uiState.models = models
}
// Remaining preferences are treated as stable UI settings.
const moved = new Set([
"environmentVariables",
"listeningMode",
"lastUsedBinary",
"modelRecents",
"modelFavorites",
"modelThinkingSelections",
])
Object.assign(uiSettings, omitKeys(preferences, moved))
}
// recentFolders lives in legacy state (yaml) or legacy config.json
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
if (Array.isArray(recentFolders)) {
uiState.recentFolders = recentFolders
}
// opencodeBinaries -> state.ui.opencodeBinaries
if (Array.isArray(cfg.opencodeBinaries)) {
uiState.opencodeBinaries = cfg.opencodeBinaries
}
if (Object.keys(uiSettings).length > 0) {
uiConfig.settings = uiSettings
}
if (Object.keys(uiConfig).length > 0) {
outConfig.ui = uiConfig
}
if (Object.keys(serverConfig).length > 0) {
outConfig.server = serverConfig
}
if (Object.keys(uiState).length > 0) {
outState.ui = uiState
}
// Unknown top-level keys -> legacy.unknown
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
const unknownConfig = omitKeys(cfg, knownConfigKeys)
if (Object.keys(unknownConfig).length > 0) {
outConfig.legacy = { unknown: unknownConfig }
}
const knownStateKeys = new Set(["recentFolders"])
const unknownState = omitKeys(st, knownStateKeys)
if (Object.keys(unknownState).length > 0) {
outState.legacy = { unknown: unknownState }
}
return { config: outConfig, state: outState }
}
/**
* Migrate older config/state layouts into owner-bucket YAML docs.
*
* Legacy inputs supported:
* - config.yaml with { preferences, opencodeBinaries, theme }
* - state.yaml with { recentFolders }
* - legacy config.json with full ConfigFile schema
*/
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
const configYamlPath = location.configYamlPath
const stateYamlPath = location.stateYamlPath
const legacyJsonPath = location.legacyJsonPath
const configExists = fs.existsSync(configYamlPath)
const stateExists = fs.existsSync(stateYamlPath)
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
if (configIsNew && stateIsNew) {
return
}
const legacyJsonExists = fs.existsSync(legacyJsonPath)
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
const shouldMigrateFromJson = !configExists && legacyJsonExists
if (!hasLegacyYaml && !shouldMigrateFromJson) {
// Either fresh install or partially written docs; let stores create on first write.
return
}
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
try {
fs.mkdirSync(location.baseDir, { recursive: true })
} catch (error) {
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
}
// Backup legacy files before rewriting.
if (configExists) {
try {
const bak = pickBackupPath(configYamlPath)
fs.renameSync(configYamlPath, bak)
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
} catch (error) {
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
}
}
if (stateExists) {
try {
const bak = pickBackupPath(stateYamlPath)
fs.renameSync(stateYamlPath, bak)
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
} catch (error) {
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
}
}
if (shouldMigrateFromJson) {
try {
const bak = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bak)
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
} catch (error) {
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
}
}
writeYaml(configYamlPath, config, logger)
writeYaml(stateYamlPath, state, logger)
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
}

View File

@@ -1,55 +0,0 @@
import type { Logger } from "../logger"
import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
export type DocKind = "config" | "state"
export class SettingsService {
private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore
constructor(
private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {
migrateSettingsLayout(location, logger)
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
}
getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get()
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
} as any
this.eventBus.publish(payload)
}
}

View File

@@ -1,110 +0,0 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import { applyMergePatch, isPlainObject } from "./merge-patch"
export type SettingsDoc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function normalizeDoc(input: unknown): SettingsDoc {
if (!isPlainObject(input)) {
return {}
}
return input
}
export class YamlDocStore {
private cache: SettingsDoc = {}
private loaded = false
constructor(
private readonly filePath: string,
private readonly logger: Logger,
) {}
load(): SettingsDoc {
if (this.loaded) {
return this.cache
}
try {
if (!fs.existsSync(this.filePath)) {
this.cache = {}
this.loaded = true
return this.cache
}
const content = fs.readFileSync(this.filePath, "utf-8")
const parsed = parseYaml(content)
this.cache = normalizeDoc(parsed)
this.loaded = true
return this.cache
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
this.cache = {}
this.loaded = true
return this.cache
}
}
get(): SettingsDoc {
return this.load()
}
replace(next: unknown): SettingsDoc {
const normalized = normalizeDoc(next)
this.cache = normalized
this.loaded = true
this.persist()
return this.cache
}
mergePatch(patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current, patch)
return this.replace(next)
}
getOwner(owner: string): SettingsDoc {
const doc = this.get()
const value = (doc as any)?.[owner]
return normalizeDoc(value)
}
replaceOwner(owner: string, value: unknown): SettingsDoc {
const doc = this.get()
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
this.replace(nextDoc)
return nextDoc[owner] as SettingsDoc
}
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const doc = this.get()
const currentOwner = normalizeDoc((doc as any)?.[owner])
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
this.replace(nextDoc)
return nextOwner
}
private persist() {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
const yaml = stringifyYaml(this.cache as any)
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
}
}
}

View File

@@ -2,8 +2,8 @@ import path from "path"
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { connect } from "net" import { connect } from "net"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import type { SettingsService } from "../settings/service" import { ConfigStore } from "../config/store"
import type { BinaryResolver } from "../settings/binaries" import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser" 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"
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions { interface WorkspaceManagerOptions {
rootDir: string rootDir: string
settings: SettingsService configStore: ConfigStore
binaryResolver: BinaryResolver binaryRegistry: BinaryRegistry
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
getServerBaseUrl: () => string getServerBaseUrl: () => string
@@ -86,7 +86,7 @@ export class WorkspaceManager {
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> { async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`
const binary = this.options.binaryResolver.resolveDefault() const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path) const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath) clearWorkspaceSearchCache(workspacePath)
@@ -109,14 +109,17 @@ 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)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const serverConfig = this.options.settings.getOwner("config", "server") const preferences = this.options.configStore.get().preferences ?? {}
const envVars = (serverConfig as any)?.environmentVariables const userEnvironment = preferences.environmentVariables ?? {}
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword() const opencodePassword = generateOpencodeServerPassword()
@@ -145,10 +148,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 +277,42 @@ export class WorkspaceManager {
return candidates[0] ?? "" return candidates[0] ?? ""
} }
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: 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 +326,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 +339,6 @@ export class WorkspaceManager {
) )
}), }),
]) ])
return version
} }
private async waitForInstanceHealth(params: { private async waitForInstanceHealth(params: {
@@ -318,7 +346,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 +360,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 +371,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 +383,11 @@ export class WorkspaceManager {
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `/global/health returned HTTP ${response.status}` const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason } return { ok: false, reason }
} }
return { ok: true }
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
const healthy = payload?.healthy === true
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
if (!healthy) {
const reason = "Instance reported unhealthy"
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
return { ok: false, reason }
}
return { ok: true, version: version || undefined }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error) const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")

View File

@@ -8,8 +8,6 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) { export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
@@ -42,61 +40,6 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
} }
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> { function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

@@ -636,7 +636,6 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -3895,19 +3894,6 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.12.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -5029,12 +5015,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"

View File

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

View File

@@ -11,7 +11,6 @@ tauri-build = { version = "2.5.2", features = [] }
tauri = { version = "2.5.2", features = [ "devtools"] } tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
regex = "1" regex = "1"
once_cell = "1" once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"

View File

@@ -140,45 +140,17 @@ struct PreferencesConfig {
listening_mode: Option<String>, listening_mode: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct ServerConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct AppConfig { struct AppConfig {
preferences: Option<PreferencesConfig>, preferences: Option<PreferencesConfig>,
server: Option<ServerConfig>,
} }
fn resolve_config_locations() -> (PathBuf, PathBuf) { fn resolve_config_path() -> PathBuf {
let raw = env::var("CLI_CONFIG") let raw = env::var("CLI_CONFIG")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
let expanded = expand_home(&raw);
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (expanded, base.join("config.json"));
}
if lower.ends_with(".json") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (base.join("config.yaml"), expanded);
}
// Treat as directory.
(expanded.join("config.yaml"), expanded.join("config.json"))
} }
fn expand_home(path: &str) -> PathBuf { fn expand_home(path: &str) -> PathBuf {
@@ -191,46 +163,14 @@ fn expand_home(path: &str) -> PathBuf {
} }
fn resolve_listening_mode() -> String { fn resolve_listening_mode() -> String {
let (yaml_path, json_path) = resolve_config_locations(); let path = resolve_config_path();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(content) = fs::read_to_string(&yaml_path) {
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
let mode = config
.server
.as_ref()
.and_then(|srv| srv.listening_mode.as_ref())
.or_else(|| {
config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
});
if let Some(mode) = mode {
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
// Legacy fallback.
if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) { if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
let mode = config if let Some(mode) = config
.server .preferences
.as_ref() .as_ref()
.and_then(|srv| srv.listening_mode.as_ref()) .and_then(|prefs| prefs.listening_mode.as_ref())
.or_else(|| { {
config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
});
if let Some(mode) = mode {
if mode == "local" { if mode == "local" {
return "local".to_string(); return "local".to_string();
} }
@@ -320,14 +260,7 @@ impl CliProcessManager {
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
if let Err(err) = Self::spawn_cli( if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
app.clone(),
status_arc.clone(),
child_arc,
ready_flag,
token_arc,
dev,
) {
log_line(&format!("cli spawn failed: {err}")); log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock(); let mut locked = status_arc.lock();
locked.state = CliState::Error; locked.state = CliState::Error;
@@ -436,9 +369,7 @@ impl CliProcessManager {
if !supports_user_shell() { if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() { if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
"Node binary not found. Make sure Node.js is installed."
));
} }
} }
@@ -489,6 +420,7 @@ impl CliProcessManager {
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
.lock() .lock()
.as_mut() .as_mut()
@@ -501,24 +433,10 @@ impl CliProcessManager {
.map(BufReader::new); .map(BufReader::new);
if let Some(reader) = stdout { if let Some(reader) = stdout {
Self::process_stream( Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
Self::process_stream( Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
}); });
@@ -591,14 +509,8 @@ impl CliProcessManager {
if locked.error.is_none() { if locked.error.is_none() {
locked.error = err_msg.clone(); locked.error = err_msg.clone();
} }
log_line(&format!( log_line(&format!("cli process exited before ready: {:?}", locked.error));
"cli process exited before ready: {:?}", let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
locked.error
));
let _ = app_clone.emit(
"cli:error",
json!({"message": locked.error.clone().unwrap_or_default()}),
);
} else { } else {
locked.state = CliState::Stopped; locked.state = CliState::Stopped;
log_line("cli process stopped cleanly"); log_line("cli process stopped cleanly");
@@ -662,25 +574,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready( Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{port}"),
);
continue; continue;
} }
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) { if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready( Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{}", port),
);
continue; continue;
} }
} }
@@ -819,12 +719,7 @@ impl CliEntry {
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![ let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--generate-token".to_string(),
];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: plain HTTP + Vite dev server proxy.
@@ -866,10 +761,9 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
std::env::current_dir() std::env::current_dir()
.ok() .ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")), .map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe().ok().and_then(|ex| { std::env::current_exe()
ex.parent() .ok()
.map(|p| p.join("../node_modules/tsx/dist/cli.js")) .and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
}),
]; ];
first_existing(candidates) first_existing(candidates)
@@ -892,8 +786,7 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root(); let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![ let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref() base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")), base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")), base.as_ref().map(|p| p.join("server/dist/index.js")),
]; ];
@@ -908,9 +801,7 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
candidates.push(Some(resources.join("resources/server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some( candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
resources.join("resources/server/dist/server/index.js"),
));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots { for root in linux_resource_roots {
@@ -929,10 +820,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
first_existing(candidates) first_existing(candidates)
} }
fn build_shell_command_string( fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
entry: &CliEntry,
cli_args: &[String],
) -> anyhow::Result<ShellCommand> {
let shell = default_shell(); let shell = default_shell();
let mut quoted: Vec<String> = Vec::new(); let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary)); quoted.push(shell_escape(&entry.node_binary));
@@ -963,7 +852,7 @@ fn shell_escape(input: &str) -> String {
"''".to_string() "''".to_string()
} else if !input } else if !input
.chars() .chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
{ {
input.to_string() input.to_string()
} else { } else {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.2", "version": "0.10.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -30,8 +30,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0", "tauri-plugin-keepawake-api": "^0.1.0"
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,8 +1,6 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast" import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog" import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view" import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts" import { showConfirmDialog } from "./stores/alerts"
@@ -18,8 +16,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"
@@ -62,10 +58,8 @@ const App: Component = () => {
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
serverSettings,
recordWorkspaceLaunch, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
@@ -74,116 +68,24 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
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)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
// In-memory only: hides chrome on phone; may also request browser fullscreen.
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
const fullscreenSupported = () => {
if (typeof document === "undefined") return false
const el = document.documentElement as any
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
}
const syncBrowserFullscreenState = () => {
if (typeof document === "undefined") return
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
}
const enterMobileFullscreen = async () => {
if (!isPhoneLayout()) return
setMobileFullscreenMode(true)
if (!fullscreenSupported()) return
try {
await document.documentElement.requestFullscreen()
} catch {
// Ignore: immersive mode still works without browser fullscreen.
}
}
const exitMobileFullscreen = async () => {
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
try {
await document.exitFullscreen()
} catch {
// Ignore
}
}
setMobileFullscreenMode(false)
}
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
const updateInstanceTabBarHeight = () => { const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance") const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0) setInstanceTabBarHeight(element?.offsetHeight ?? 0)
} }
onMount(() => {
if (typeof document === "undefined") return
syncBrowserFullscreenState()
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
})
onMount(() => {
if (typeof window === "undefined") return
const vv = window.visualViewport
if (!vv) return
const updateKeyboardOffset = () => {
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
}
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
schedule()
vv.addEventListener("resize", schedule)
vv.addEventListener("scroll", schedule)
window.addEventListener("orientationchange", schedule)
onCleanup(() => {
vv.removeEventListener("resize", schedule)
vv.removeEventListener("scroll", schedule)
window.removeEventListener("orientationchange", schedule)
document.documentElement.style.removeProperty("--keyboard-offset")
})
})
// If the user exits browser fullscreen via browser UI, restore chrome.
let lastBrowserFullscreen = false
createEffect(() => {
const active = browserFullscreenActive()
const mode = mobileFullscreenMode()
if (mode && lastBrowserFullscreen && !active) {
setMobileFullscreenMode(false)
}
lastBrowserFullscreen = active
})
// If we leave phone layout (rotation / resize), restore chrome.
createEffect(() => {
if (!isPhoneLayout() && mobileFullscreenMode()) {
void exitMobileFullscreen()
}
})
createEffect(() => { createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
}) })
@@ -241,12 +143,41 @@ 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
} }
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
recordWorkspaceLaunch(folderPath, selectedBinary) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
@@ -259,9 +190,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)
@@ -358,7 +293,6 @@ const App: Component = () => {
preferences, preferences,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
@@ -366,7 +300,6 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,
@@ -462,62 +395,37 @@ const App: Component = () => {
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}> <div class="h-screen w-screen flex flex-col">
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
<div class="mobile-fullscreen-exit-wrapper">
<button
type="button"
class="message-scroll-button mobile-fullscreen-exit-button"
onClick={() => void exitMobileFullscreen()}
aria-label={t("instanceShell.fullscreen.exit")}
title={t("instanceShell.fullscreen.exit")}
>
<Minimize2 class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show <Show
when={!hasInstances()} when={!hasInstances()}
fallback={ fallback={
<> <>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}> <InstanceTabs
<InstanceTabs instances={instances()}
instances={instances()} activeInstanceId={activeInstanceId()}
activeInstanceId={activeInstanceId()} onSelect={setActiveInstanceId}
onSelect={setActiveInstanceId} onClose={handleCloseInstance}
onClose={handleCloseInstance} onNew={handleNewInstanceRequest}
onNew={handleNewInstanceRequest} onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)} />
/>
</Show>
<For each={Array.from(instances().values())}> <For each={Array.from(instances().values())}>
{(instance) => { {(instance) => {
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()} tabBarOffset={instanceTabBarHeight()}
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()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/> />
</InstanceMetadataProvider> </InstanceMetadataProvider>
@@ -543,17 +451,25 @@ const App: Component = () => {
<Show when={showFolderSelection()}> <Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"> <div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<button
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()} advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)} onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/> />
</div> </div>
</div> </div>

View File

@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
const availableAgents = createMemo(() => { const availableAgents = createMemo(() => {
const allAgents = instanceAgents() const allAgents = instanceAgents()
if (isChildSession()) { if (isChildSession()) {
return allAgents.filter((agent) => !agent.hidden) return allAgents
} }
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent") const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent) const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Select.Value<Agent>> <Select.Value<Agent>>
{() => ( {(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked"> <div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })} {t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
</span> </span>
</div> </div>
)} )}

View File

@@ -115,28 +115,28 @@ 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 class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<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={{
"background-color": accent.badgeBg, "background-color": accent.badgeBg,
"border-color": accent.badgeBorder, "border-color": accent.badgeBorder,
color: accent.badgeText, color: accent.badgeText,
}} }}
aria-hidden aria-hidden
> >
{accent.symbol} {accent.symbol}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-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 whitespace-pre-line break-words">
{payload.message} {payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>} {payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description> </Dialog.Description>
</div> </div>
</div> </div>
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
) )
}} }}
</Show> </Show>
) )
} }
export default AlertDialog export default AlertDialog

View File

@@ -112,10 +112,6 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const groupedCommandList = () => processedCommands().groups const groupedCommandList = () => processedCommands().groups
const orderedCommands = () => processedCommands().ordered const orderedCommands = () => processedCommands().ordered
const isCommandDisabled = (command: Command) => {
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
}
const selectedIndex = createMemo(() => { const selectedIndex = createMemo(() => {
const ordered = orderedCommands() const ordered = orderedCommands()
if (ordered.length === 0) return -1 if (ordered.length === 0) return -1
@@ -142,11 +138,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
} }
return return
} }
const currentId = selectedCommandId() const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) { if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd)) setSelectedCommandId(ordered[0].id)
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
} }
}) })
@@ -200,14 +195,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
if (index < 0 || index >= ordered.length) return if (index < 0 || index >= ordered.length) return
const command = ordered[index] const command = ordered[index]
if (!command) return if (!command) return
if (isCommandDisabled(command)) return
props.onExecute(command) props.onExecute(command)
props.onClose() props.onClose()
} }
} }
function handleCommandClick(command: Command) { function handleCommandClick(command: Command) {
if (isCommandDisabled(command)) return
props.onExecute(command) props.onExecute(command)
props.onClose() props.onClose()
} }
@@ -272,13 +265,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<For each={group.commands}> <For each={group.commands}>
{(command, localIndex) => { {(command, localIndex) => {
const commandIndex = group.startIndex + localIndex() const commandIndex = group.startIndex + localIndex()
const disabled = isCommandDisabled(command)
return ( return (
<button <button
type="button" type="button"
data-command-index={commandIndex} data-command-index={commandIndex}
onClick={() => handleCommandClick(command)} onClick={() => handleCommandClick(command)}
disabled={disabled}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`} class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => { onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return if (event.movementX === 0 && event.movementY === 0) return

View File

@@ -1,123 +0,0 @@
import type { Component } from "solid-js"
interface ContextMeterProps {
usedTokens: number
availableTokens: number | null
formatTokens: (value: number) => string
usedLabel: string
availableLabel: string
class?: string
}
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveFillColor(percent: number): string {
if (percent >= 0.8) return "var(--status-error)"
if (percent >= 0.6) return "var(--status-warning)"
return "var(--status-success)"
}
export const ContextMeter: Component<ContextMeterProps> = (props) => {
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
const percent = () => {
const usedValue = used()
const availableValue = available()
if (availableValue === null || availableValue <= 0) return null
// Heuristic: if available >= used, treat it like a capacity/limit.
// Otherwise treat it like remaining tokens.
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
return clamp(ratio, 0, 1)
}
const fillColor = () => {
const value = percent()
if (value === null) return "var(--border-base)"
return resolveFillColor(value)
}
const percentLabel = () => {
const value = percent()
if (value === null) return "--"
return `${Math.round(value * 100)}%`
}
const containerClass =
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
}
}
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, startAngle)
const end = polarToCartesian(cx, cy, r, endAngle)
const delta = ((endAngle - startAngle) % 360 + 360) % 360
const largeArc = delta > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
const circle = () => {
const value = percent()
const size = 22
const r = 9
const cx = 11
const cy = 11
const progress = value === null ? 0 : value
const startAngle = -90
const endAngle = startAngle + progress * 360
const isFull = progress >= 0.999
const hasFill = progress > 0.001
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
return (
<svg
width={size}
height={size}
viewBox="0 0 22 22"
aria-hidden="true"
style={{ flex: "0 0 auto" }}
>
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
{isFull ? (
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
) : sectorPath ? (
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
) : null}
</svg>
)
}
const tooltipText = () => `Context Used: ${percentLabel()}`
return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
</div>
)
}
export default ContextMeter

View File

@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => { const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const { const {
serverSettings, preferences,
addEnvironmentVariable, addEnvironmentVariable,
removeEnvironmentVariable, removeEnvironmentVariable,
updateEnvironmentVariables, updateEnvironmentVariables,
} = useConfig() } = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {}) const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [newKey, setNewKey] = createSignal("") const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("") const [newValue, setNewValue] = createSignal("")

View File

@@ -12,7 +12,6 @@ interface MonacoDiffViewerProps {
after: string after: string
viewMode?: "split" | "unified" viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed" contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
} }
export function MonacoDiffViewer(props: MonacoDiffViewerProps) { export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -55,17 +54,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
renderWhitespace: "selection", renderWhitespace: "selection",
fontSize: 13, fontSize: 13,
wordWrap: props.wordWrap === "on" ? "on" : "off", wordWrap: "off",
glyphMargin: false, glyphMargin: false,
folding: false, folding: false,
// 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)
@@ -87,7 +81,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
if (!ready() || !monaco || !diffEditor) return if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split" const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded" const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
diffEditor.updateOptions({ diffEditor.updateOptions({
renderSideBySide: viewMode === "split", renderSideBySide: viewMode === "split",
@@ -96,20 +89,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
contextMode === "collapsed" contextMode === "collapsed"
? { enabled: true } ? { enabled: true }
: { enabled: false }, : { enabled: false },
wordWrap,
}) })
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
}) })
createEffect(() => { createEffect(() => {

View File

@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</Show> </Show>
</div> </div>
<div class="panel-footer keyboard-hints"> <div class="panel-footer">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd> <kbd class="kbd"></kbd>

View File

@@ -1,6 +1,6 @@
import { Select } from "@kobalte/core/select" import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -23,15 +23,14 @@ interface FolderSelectionViewProps {
onAdvancedSettingsOpen?: () => void onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void onOpenRemoteAccess?: () => void
onClose?: () => void
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig() const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs() const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
@@ -54,7 +53,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change // Update selected binary when preferences change
createEffect(() => { createEffect(() => {
const lastUsed = serverSettings().opencodeBinary const lastUsed = preferences().lastUsedBinary
if (!lastUsed) return if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed)) setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
}) })
@@ -374,18 +373,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()} onClick={() => props.onOpenRemoteAccess?.()}
> >
<MonitorUp class="w-4 h-4" /> <MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onClose?.()}
aria-label={t("app.launchError.close")}
title={t("app.launchError.closeTitle")}
>
<X class="w-4 h-4" />
</button> </button>
</Show> </Show>
</div> </div>
@@ -560,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")} : t("folderSelection.browse.button")}
</span> </span>
</div> </div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" /> <Kbd shortcut="cmd+n" class="ml-2" />
</button> </button>
</div> </div>
@@ -585,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints"> <div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<Show when={folders().length > 0}> <Show when={folders().length > 0}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -603,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" class="kbd-hint" /> <Kbd shortcut="cmd+n" />
<span>{t("folderSelection.hints.browse")}</span> <span>{t("folderSelection.hints.browse")}</span>
</div> </div>
</div> </div>

View File

@@ -3,15 +3,10 @@ import { Component, JSX } from "solid-js"
interface HintRowProps { interface HintRowProps {
children: JSX.Element children: JSX.Element
class?: string class?: string
ariaHidden?: boolean
} }
const HintRow: Component<HintRowProps> = (props) => { const HintRow: Component<HintRowProps> = (props) => {
return ( return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
{props.children}
</span>
)
} }
export default HintRow export default HintRow

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info" import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return ( return (
<div class="log-container"> <div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden"> <div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none"> <div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show> <Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
</div> </div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden"> <div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,21 +1,14 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js" import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status" import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps { interface InstanceInfoProps {
instance: Instance instance: Instance
compact?: boolean compact?: boolean
showDisposeButton?: boolean
} }
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => { const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext() const metadataContext = useOptionalInstanceMetadataContext()
@@ -23,8 +16,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor() const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor() const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -34,46 +25,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : [] return env ? Object.entries(env) : []
}) })
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return ( return (
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -205,19 +156,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div> </div>
</div> </div>
) )

View File

@@ -11,11 +11,20 @@ interface InstanceTabProps {
onClose: () => void onClose: () => void
} }
function getPathBasename(path: string): string { function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
// Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...). const name = path.split("/").pop() || path
// Normalize by trimming trailing separators and then splitting on both '/' and '\\'.
const normalized = path.replace(/[\\/]+$/, "") const duplicates = instances.filter((i) => {
return normalized.split(/[\\/]/).pop() || path const iName = i.folder.split("/").pop() || i.folder
return iName === name
})
if (duplicates.length > 1) {
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
return `~/${name} (${index + 1})`
}
return `~/${name}`
} }
const InstanceTab: Component<InstanceTabProps> = (props) => { const InstanceTab: Component<InstanceTabProps> = (props) => {
@@ -49,7 +58,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
> >
<FolderOpen class="w-4 h-4 flex-shrink-0" /> <FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label"> <span class="tab-label">
{getPathBasename(props.instance.folder)} {props.instance.folder.split("/").pop() || props.instance.folder}
</span> </span>
<span <span
class={`status-indicator session-status ml-auto ${statusClassName()}`} class={`status-indicator session-status ml-auto ${statusClassName()}`}

View File

@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
)} )}
<span>{t("instanceWelcome.new.createButton")}</span> <span>{t("instanceWelcome.new.createButton")}</span>
</div> </div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" /> <Kbd shortcut={newSessionShortcutString()} class="ml-2" />
</button> </button>
</div> </div>
</div> </div>
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div> </div>
</Show> </Show>
<div class="panel-footer hidden sm:block keyboard-hints"> <div class="panel-footer hidden sm:block">
<div class="panel-footer-hints"> <div class="panel-footer-hints">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">

View File

@@ -29,7 +29,6 @@ import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal" import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
@@ -42,7 +41,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel" import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status" import { getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid" import { ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
import { import {
@@ -62,9 +61,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
@@ -73,11 +69,6 @@ interface InstanceShellProps {
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void> handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void onExecuteCommand: (command: Command) => void
tabBarOffset: number tabBarOffset: number
// In-memory only: mobile immersive/fullscreen mode.
mobileFullscreenMode: boolean
onEnterMobileFullscreen: () => void
onExitMobileFullscreen: () => void
} }
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
@@ -118,7 +109,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,9 +117,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 compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -362,6 +349,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
measureDrawerHost, measureDrawerHost,
}) })
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
const formattedAvailableTokens = () => {
const avail = tokenStats().avail
if (typeof avail === "number") {
return formatTokenTotal(avail)
}
return "--"
}
const renderLeftPanel = () => { const renderLeftPanel = () => {
if (leftPinned()) { if (leftPinned()) {
@@ -597,14 +594,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{renderLeftPanel()} {renderLeftPanel()}
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}> <Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
<Show when={!mobileFullscreen()}> <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={!isPhoneLayout()}
when={!compactHeaderLayout()} 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">
<Show when={leftDrawerState() === "floating-closed"}> <Show when={leftDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setLeftToggleButtonEl} ref={setLeftToggleButtonEl}
@@ -630,17 +626,17 @@ 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 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" }}
> >
{t("instanceShell.commandPalette.button")} {t("instanceShell.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-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,18 +647,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span> </span>
</div> </div>
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
<Show when={rightDrawerState() === "floating-closed"}> <Show when={rightDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setRightToggleButtonEl} ref={setRightToggleButtonEl}
@@ -675,19 +659,22 @@ 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()}> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<ContextMeter <span class="uppercase text-[10px] tracking-wide text-muted">
usedTokens={tokenStats().used} {t("instanceShell.metrics.usedLabel")}
availableTokens={tokenStats().avail} </span>
formatTokens={formatTokenTotal} <span class="font-semibold text-primary">{formattedUsedTokens()}</span>
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</Show>
</div> </div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div> </div>
} }
> >
@@ -706,13 +693,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<Show when={!showingInfoView()}> <Show when={!showingInfoView()}>
<ContextMeter <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
usedTokens={tokenStats().used} <span class="uppercase text-[10px] tracking-wide text-muted">
availableTokens={tokenStats().avail} {t("instanceShell.metrics.usedLabel")}
formatTokens={formatTokenTotal} </span>
usedLabel={t("instanceShell.metrics.usedLabel")} <span class="font-semibold text-primary">{formattedUsedTokens()}</span>
availableLabel={t("instanceShell.metrics.availableLabel")} </div>
/> <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show> </Show>
<div class="ml-auto flex items-center session-header-hints"> <div class="ml-auto flex items-center session-header-hints">
@@ -728,7 +720,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 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" }}
@@ -738,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div> </div>
<div class="session-toolbar-right flex-1 flex items-center gap-3"> <div class="session-toolbar-right flex-1 flex items-center gap-3">
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
@@ -777,10 +769,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
</Show> </Show>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
</Show>
<Box <Box
component="main" component="main"
@@ -803,14 +794,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
@@ -819,8 +808,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId={props.instance.id} instanceId={props.instance.id}
instanceFolder={props.instance.folder} instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce} escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
showSidebarToggle={showEmbeddedSidebarToggle()} showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)} onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()} forceCompactStatusLayout={showEmbeddedSidebarToggle()}
@@ -846,10 +833,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<> <>
<div <div class="instance-shell2 flex flex-col flex-1 min-h-0">
class="instance-shell2 flex flex-col flex-1 min-h-0"
data-instance-id={props.instance.id}
>
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
{sessionLayout} {sessionLayout}
</Show> </Show>

View File

@@ -1,10 +1,10 @@
import { Show, type Accessor, type Component } from "solid-js" import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state" import type { SessionThread } from "../../../stores/session-state"
import type { Session } from "../../../types/session" import type { Session } from "../../../types/session"
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry" import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
import type { DrawerViewState } from "./types" import type { DrawerViewState } from "./types"
import { PlusSquare, Search } from "lucide-solid" import { Search } from "lucide-solid"
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"
import PushPinIcon from "@suid/icons-material/PushPin" import PushPinIcon from "@suid/icons-material/PushPin"
@@ -13,6 +13,7 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import SessionList from "../../session-list" import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint" import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector" import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector" import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector" import ModelSelector from "../../model-selector"
@@ -55,20 +56,6 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
{props.t("instanceShell.leftPanel.sessionsTitle")} {props.t("instanceShell.leftPanel.sessionsTitle")}
</span> </span>
<div class="flex items-center gap-2 text-primary"> <div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
@@ -84,7 +71,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
}, },
}} }}
> >
<Search class="w-5 h-5" /> <Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
@@ -165,17 +152,11 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} /> <ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<KeyboardHint <div class="session-sidebar-selector-hints" aria-hidden="true">
class="session-sidebar-selector-hints" <Kbd shortcut="cmd+shift+a" />
ariaHidden={true} <Kbd shortcut="cmd+shift+m" />
shortcuts={[ <Kbd shortcut="cmd+shift+t" />
keyboardRegistry.get("open-agent-selector"), </div>
keyboardRegistry.get("focus-model"),
keyboardRegistry.get("focus-variant"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
separator=" "
showDescription={false}
/>
</div> </div>
</> </>
)} )}

View File

@@ -7,7 +7,7 @@ import {
type Accessor, type Accessor,
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"
@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session" import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types" import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab" import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab" import FilesTab from "./tabs/FilesTab"
@@ -32,7 +32,6 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
@@ -103,9 +102,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>( const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
) )
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -199,11 +195,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
}) })
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
const clampSplitWidth = (value: number) => { const clampSplitWidth = (value: number) => {
const min = 200 const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -747,10 +738,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onSelectFile={handleSelectChangesFile} onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode} diffViewMode={diffViewMode}
diffContextMode={diffContextMode} diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen} listOpen={changesListOpen}
onToggleList={toggleChangesList} onToggleList={toggleChangesList}
splitWidth={changesSplitWidth} splitWidth={changesSplitWidth}
@@ -776,10 +765,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
scopeKey={gitScopeKey} scopeKey={gitScopeKey}
diffViewMode={diffViewMode} diffViewMode={diffViewMode}
diffContextMode={diffContextMode} diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path) => void openGitFile(path)} onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()} onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen} listOpen={gitChangesListOpen}

View File

@@ -1,61 +1,50 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid" import type { DiffContextMode, DiffViewMode } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps { interface DiffToolbarProps {
viewMode: DiffViewMode viewMode: DiffViewMode
contextMode: DiffContextMode contextMode: DiffContextMode
wordWrapMode: DiffWordWrapMode
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
} }
const DiffToolbar: Component<DiffToolbarProps> = (props) => { const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
return ( return (
<div class="file-viewer-toolbar"> <div class="file-viewer-toolbar">
<button <button
type="button" type="button"
class="file-viewer-toolbar-icon-button" class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
onClick={() => props.onViewModeChange(nextViewMode())} aria-pressed={props.viewMode === "split"}
aria-label={viewModeTitle()} onClick={() => props.onViewModeChange("split")}
title={viewModeTitle()}
> >
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />} Split
</button> </button>
<button <button
type="button" type="button"
class="file-viewer-toolbar-icon-button" class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
onClick={() => props.onContextModeChange(nextContextMode())} aria-pressed={props.viewMode === "unified"}
aria-label={contextModeTitle()} onClick={() => props.onViewModeChange("unified")}
title={contextModeTitle()}
> >
{nextContextMode() === "collapsed" ? ( Unified
<FoldVertical class="h-4 w-4" aria-hidden="true" />
) : (
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
)}
</button> </button>
<button <button
type="button" type="button"
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`} class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())} aria-pressed={props.contextMode === "collapsed"}
aria-label={wordWrapTitle()} onClick={() => props.onContextModeChange("collapsed")}
title={wordWrapTitle()} title="Hide unchanged regions"
> >
<WrapText class="h-4 w-4" aria-hidden="true" /> Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
</button> </button>
</div> </div>
) )

View File

@@ -1,10 +1,10 @@
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"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode } from "../types"
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -18,10 +18,8 @@ interface ChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
listOpen: Accessor<boolean> listOpen: Accessor<boolean>
onToggleList: () => void onToggleList: () => void
@@ -32,18 +30,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 +45,49 @@ 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-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<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,13 +96,12 @@ 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 || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
@@ -133,11 +114,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 +139,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 +164,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,23 +176,12 @@ 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>
<div style={{ "margin-left": "auto" }}>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</div>
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode } from "../types"
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -29,10 +29,8 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
onRefresh: () => void onRefresh: () => void
@@ -46,18 +44,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,36 +62,32 @@ 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">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={props.selectedLoading()} when={props.selectedLoading()}
@@ -104,12 +97,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 +115,15 @@ 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()} />
/> )}
)}
</Show> </Show>
} }
> >
@@ -154,8 +146,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 +178,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 +209,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 +231,14 @@ 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 }
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}

View File

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

View File

@@ -3,5 +3,3 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified" export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed" export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"

View File

@@ -23,7 +23,6 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const clampWidth = (value: number) => export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

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

View File

@@ -1,5 +1,4 @@
import { Component, JSX, For } from "solid-js" import { Component, JSX, For } from "solid-js"
import useMediaQuery from "@suid/material/useMediaQuery"
import { isMac } from "../lib/keyboard-utils" import { isMac } from "../lib/keyboard-utils"
interface KbdProps { interface KbdProps {
@@ -28,9 +27,6 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
} }
const Kbd: Component<KbdProps> = (props) => { const Kbd: Component<KbdProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)")
if (!desktopQuery()) return null
const parts = () => { const parts = () => {
if (props.children) return [{ text: props.children, isModifier: false }] if (props.children) return [{ text: props.children, isModifier: false }]
if (!props.shortcut) return [] if (!props.shortcut) return []

View File

@@ -1,20 +1,14 @@
import { Component, For } from "solid-js" import { Component, For } from "solid-js"
import useMediaQuery from "@suid/material/useMediaQuery" import { formatShortcut, isMac } from "../lib/keyboard-utils"
import type { KeyboardShortcut } from "../lib/keyboard-registry" import type { KeyboardShortcut } from "../lib/keyboard-registry"
import Kbd from "./kbd" import Kbd from "./kbd"
import HintRow from "./hint-row" import HintRow from "./hint-row"
const KeyboardHint: Component<{ const KeyboardHint: Component<{
shortcuts: KeyboardShortcut[] shortcuts: KeyboardShortcut[]
separator?: string | null separator?: string
showDescription?: boolean showDescription?: boolean
class?: string
ariaHidden?: boolean
}> = (props) => { }> = (props) => {
// Centralize layout gating here so call sites don't need to.
// We only show keyboard hint UI on desktop layouts.
const desktopQuery = useMediaQuery("(min-width: 1280px)")
function buildShortcutString(shortcut: KeyboardShortcut): string { function buildShortcutString(shortcut: KeyboardShortcut): string {
const parts: string[] = [] const parts: string[] = []
@@ -32,14 +26,12 @@ const KeyboardHint: Component<{
return parts.join("+") return parts.join("+")
} }
if (!desktopQuery()) return null
return ( return (
<HintRow class={props.class} ariaHidden={props.ariaHidden}> <HintRow>
<For each={props.shortcuts}> <For each={props.shortcuts}>
{(shortcut, i) => ( {(shortcut, i) => (
<> <>
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>} {i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>} {props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
<Kbd shortcut={buildShortcutString(shortcut)} /> <Kbd shortcut={buildShortcutString(shortcut)} />
</> </>

View File

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

View File

@@ -1,9 +0,0 @@
export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
export function getMessageAnchorId(messageId: string) {
return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
}
export function getMessageIdFromAnchorId(anchorId: string) {
return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
}

View File

@@ -0,0 +1,64 @@
import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}`
}
const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIds: () => string[]
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
}
export default function MessageBlockList(props: MessageBlockListProps) {
return (
<>
<Index each={props.messageIds()}>
{(messageId, index) => (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)}
</Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</>
)
}

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -12,17 +12,8 @@ import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -32,10 +23,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk/v2").ToolState type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running") return Boolean(state && state.status === "running")
@@ -203,23 +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 {
const type = (part as any)?.type
// Ignore part types the UI does not support rendering yet.
return !(typeof type === "string" && type === "patch")
}
function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file"
} }
function MessageContentItem(props: MessageContentItemProps) { function MessageContentItem(props: MessageContentItemProps) {
@@ -246,9 +222,15 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx] const partId = ids[idx]
const part = current.parts[partId]?.data const part = current.parts[partId]?.data
if (!part) continue if (!part) continue
if (!isSupportedPartType(part)) continue if (
part.type === "tool" ||
if (!isContentPartType((part as any).type)) break part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
resolved.push(part) resolved.push(part)
} }
@@ -274,9 +256,15 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx] const partId = ids[idx]
const part = current.parts[partId]?.data const part = current.parts[partId]?.data
if (!part) continue if (!part) continue
if (!isSupportedPartType(part)) continue if (
part.type === "tool" ||
if (!isContentPartType((part as any).type)) continue part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (partHasRenderableText(part)) { if (partHasRenderableText(part)) {
return false return false
} }
@@ -296,12 +284,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 +300,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 +321,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 +352,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 +397,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 +420,7 @@ function ToolCallItem(props: ToolCallItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </>
)} )}
</Show> </Show>
) )
@@ -562,13 +472,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 +483,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
@@ -669,9 +549,6 @@ export default function MessageBlock(props: MessageBlockProps) {
} }
orderedParts.forEach((part, partIndex) => { orderedParts.forEach((part, partIndex) => {
if (!isSupportedPartType(part)) {
return
}
if (part.type === "tool") { if (part.type === "tool") {
flushContent() flushContent()
const partId = part.id const partId = part.id
@@ -790,13 +667,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 +680,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 +696,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 +708,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 +717,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 +727,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 +737,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 +758,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 +767,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 +780,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 +827,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 +871,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 +901,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 +938,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 +973,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 +1013,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 +1044,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 +1060,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 +1080,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 +1097,6 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</div> </div>
<Show when={hasMeta() && !showMetaInline()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">

View File

@@ -1,24 +1,13 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { For, Show, createSignal } from "solid-js"
import { Portal } from "solid-js/web" import { Copy, 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 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 +17,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
@@ -153,15 +45,6 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts const messageParts = () => props.parts
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
// We only want to display the primary prompt text for the user message; other synthetic text
// parts should be hidden.
const primaryUserTextPartId = () => {
if (!isUser()) return null
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
return typeof firstText?.id === "string" ? firstText.id : null
}
const fileAttachments = () => const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -213,8 +96,7 @@ export default function MessageItem(props: MessageItemProps) {
} }
if (url.startsWith("file://")) { if (url.startsWith("file://")) {
// Local filesystem URLs are not reliably downloadable from the message stream. window.open(url, "_blank", "noopener")
// We hide the download action for these chips.
return return
} }
@@ -230,11 +112,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
@@ -274,8 +151,7 @@ export default function MessageItem(props: MessageItemProps) {
} }
const info = props.messageInfo const info = props.messageInfo
const timeInfo = info?.time as { created: number; end?: number } | undefined return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
} }
const handleRevert = () => { const handleRevert = () => {
@@ -302,30 +178,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 +246,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 +266,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 +298,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 +319,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 +338,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 +366,15 @@ 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} onRendered={props.onContentRendered}
sessionId={props.sessionId} />
primaryUserTextPartId={primaryUserTextPartId()} )}
onRendered={props.onContentRendered}
/>
</div>
)
}}
</For> </For>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
@@ -583,16 +384,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
@@ -606,19 +398,34 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" /> <img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show> </Show>
<span class="truncate max-w-[180px]">{name}</span> <span class="truncate max-w-[180px]">{name}</span>
<Show when={!attachment.url?.startsWith("file://")}> <button
<button type="button"
type="button" onClick={() => void handleAttachmentDownload(attachment)}
onClick={() => void handleAttachmentDownload(attachment)} class="attachment-download"
class="attachment-download" aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })} >
title={t("messageItem.attachment.downloadAriaLabel", { name })} <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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<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="M8 12l4 4 4-4m-4-8v12" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" /> </svg>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" /> </button>
</svg>
</button> <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> </Show>
</div> </div>
) )
@@ -627,31 +434,6 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")} <span class="generating-spinner"></span> {t("messageItem.status.sending")}

View File

@@ -1,8 +1,10 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import Kbd from "./kbd" import Kbd from "./kbd"
import ContextMeter from "./context-meter"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps { interface MessageListHeaderProps {
usedTokens: number usedTokens: number
@@ -19,6 +21,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n() const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number" const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return ( return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}> <div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
@@ -37,13 +40,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info"> <div class="connection-status-text connection-status-info">
<div class="connection-status-usage"> <div class="connection-status-usage">
<ContextMeter <div class={METRIC_CHIP_CLASS}>
usedTokens={props.usedTokens} <span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null} <span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
formatTokens={props.formatTokens} </div>
usedLabel={t("messageListHeader.metrics.usedLabel")} <div class={METRIC_CHIP_CLASS}>
availableLabel={t("messageListHeader.metrics.availableLabel")} <span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
/> <span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
</div> </div>
</div> </div>
@@ -51,14 +55,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-shortcut-action"> <div class="connection-status-shortcut-action">
<button <button
type="button" type="button"
class="connection-status-button command-palette-button" class="connection-status-button"
onClick={props.onCommandPalette} onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")} aria-label={t("messageListHeader.commandPalette.ariaLabel")}
> >
{t("messageListHeader.commandPalette.button")} {t("messageListHeader.commandPalette.button")}
</button> </button>
<span class="connection-status-shortcut-hint"> <span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" class="kbd-hint" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -12,39 +13,23 @@ interface MessagePartProps {
messageType?: "user" | "assistant" messageType?: "user" | "assistant"
instanceId: string instanceId: string
sessionId: string sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || "" const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}` const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId()) const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant" const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
const isSynthetic = Boolean((part as any).synthetic) return Boolean((part as any).synthetic) && props.messageType !== "user"
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
} }
@@ -58,11 +43,6 @@ export default function MessagePart(props: MessagePartProps) {
return "" return ""
} }
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean { function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") { if (typeof segment === "string") {
return segment.trim().length > 0 return segment.trim().length > 0
@@ -97,28 +77,20 @@ export default function MessagePart(props: MessagePartProps) {
const createTextPartForMarkdown = (): TextPart => { const createTextPartForMarkdown = (): TextPart => {
const part = props.part const part = props.part
if (part.type === "text" && typeof part.text === "string") { if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: (part as any).text, text: part.text,
synthetic: false, synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version, version: (part as { version?: number }).version
renderCache: (part as any).renderCache,
} }
} }
return { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: "", text: "",
synthetic: false, synthetic: false
} }
} }
@@ -131,23 +103,22 @@ 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={textContainerClass()}>
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} <Show
data-role={textContainerRole()} when={isAssistantMessage()}
data-part-type="text" fallback={<span class="text-primary">{plainTextContent()}</span>}
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} >
> <Markdown
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> part={createTextPartForMarkdown()}
<Markdown instanceId={props.instanceId}
part={createTextPartForMarkdown()} sessionId={props.sessionId}
instanceId={props.instanceId} isDark={isDark()}
sessionId={props.sessionId} size={isAssistantMessage() ? "tight" : "base"}
isDark={isDark()} onRendered={props.onRendered}
size={isAssistantMessage() ? "tight" : "base"} />
onRendered={props.onRendered} </Show>
/>
</Show> </div>
</div>
</Show> </Show>
</Match> </Match>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
import type { Model } from "../types/session" import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
const log = getLogger("session") const log = getLogger("session")
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const favoriteKeySet = createMemo(() => { const favoriteKeySet = createMemo(() => {
const result = new Set<string>() const result = new Set<string>()
for (const item of uiState().models.favorites ?? []) { for (const item of preferences().modelFavorites ?? []) {
if (item.providerId && item.modelId) { if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`) result.add(`${item.providerId}/${item.modelId}`)
} }

View File

@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
opencodeBinaries, opencodeBinaries,
addOpenCodeBinary, addOpenCodeBinary,
removeOpenCodeBinary, removeOpenCodeBinary,
serverSettings, preferences,
updateLastUsedBinary, updatePreferences,
} = useConfig() } = useConfig()
const [customPath, setCustomPath] = createSignal("") const [customPath, setCustomPath] = createSignal("")
const [validating, setValidating] = createSignal(false) const [validating, setValidating] = createSignal(false)
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
const lastUsedBinary = () => serverSettings().opencodeBinary const lastUsedBinary = () => preferences().lastUsedBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (validation.valid) { if (validation.valid) {
addOpenCodeBinary(path, validation.version) addOpenCodeBinary(path, validation.version)
props.onBinaryChange(path) props.onBinaryChange(path)
updateLastUsedBinary(path) updatePreferences({ lastUsedBinary: path })
setCustomPath("") setCustomPath("")
setValidationError(null) setValidationError(null)
} else { } else {
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.disabled) return if (props.disabled) return
if (path === props.selectedBinary) return if (path === props.selectedBinary) return
props.onBinaryChange(path) props.onBinaryChange(path)
updateLastUsedBinary(path) updatePreferences({ lastUsedBinary: path })
} }
function handleRemoveBinary(path: string, event: Event) { function handleRemoveBinary(path: string, event: Event) {
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.selectedBinary === path) { if (props.selectedBinary === path) {
props.onBinaryChange("opencode") props.onBinaryChange("opencode")
updateLastUsedBinary("opencode") updatePreferences({ lastUsedBinary: "opencode" })
} }
} }

View File

@@ -1,8 +1,8 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
@@ -63,7 +63,6 @@ export default function PromptInput(props: PromptInputProps) {
handleDrop, handleDrop,
syncAttachmentCounters, syncAttachmentCounters,
handleExpandTextAttachment, handleExpandTextAttachment,
handleRemoveAttachment,
} = usePromptAttachments({ } = usePromptAttachments({
instanceId: () => props.instanceId, instanceId: () => props.instanceId,
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
@@ -88,9 +87,6 @@ export default function PromptInput(props: PromptInputProps) {
if (!attachment) return if (!attachment) return
handleExpandTextAttachment(attachment) handleExpandTextAttachment(attachment)
}, },
removeAttachment: (attachmentId: string) => {
handleRemoveAttachment(attachmentId)
},
setPromptText: (text: string, opts?: { focus?: boolean }) => { setPromptText: (text: string, opts?: { focus?: boolean }) => {
const textarea = textareaRef const textarea = textareaRef
if (textarea) { if (textarea) {
@@ -170,32 +166,24 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null) setAtPosition(null)
setSearchQuery("") setSearchQuery("")
syncAttachmentCounters(prompt()) const instanceId = props.instanceId
const sessionId = props.sessionId
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
syncAttachmentCounters(prompt(), currentAttachments)
}, },
{ defer: true }, { defer: true },
), ),
) )
const isCoarsePointer = () => { onMount(() => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
if (isCoarsePointer()) return
if (props.isActive === false) return
if (props.disabled) return
const handleGlobalKeyDown = (e: KeyboardEvent) => { const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | null const activeElement = document.activeElement as HTMLElement
const isInputElement = const isInputElement =
activeElement?.tagName === "INPUT" || activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" || activeElement?.tagName === "SELECT" ||
Boolean(activeElement?.isContentEditable) activeElement?.isContentEditable
if (isInputElement) return if (isInputElement) return
@@ -203,25 +191,16 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return if (isModifierKey) return
const isSpecialKey = const isSpecialKey =
e.key === "Tab" || e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return if (isSpecialKey) return
const textarea = textareaRef if (e.key.length === 1 && textareaRef && !props.disabled) {
if (!textarea || textarea.disabled) return textareaRef.focus()
// In session cache mode inactive panes are display:none; avoid stealing focus.
if (textarea.offsetParent === null) return
if (e.key.length === 1) {
textarea.focus()
} }
} }
document.addEventListener("keydown", handleGlobalKeyDown) document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown) document.removeEventListener("keydown", handleGlobalKeyDown)
}) })
@@ -259,10 +238,10 @@ export default function PromptInput(props: PromptInputProps) {
// Ignore attachments for slash commands, but keep them for next prompt. // Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) { if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId) clearAttachments(props.instanceId, props.sessionId)
syncAttachmentCounters("") syncAttachmentCounters("", [])
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
syncAttachmentCounters("") syncAttachmentCounters("", currentAttachments)
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -351,9 +330,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) {
@@ -457,7 +434,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
disabled={props.disabled} disabled={props.disabled}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3} rows={expandState() === "expanded" ? 15 : 4}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autoCapitalize="off" autoCapitalize="off"
@@ -502,7 +479,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show> </Show>
</div> </div>
<Show when={shouldShowOverlay()}> <Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}> <div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show <Show
when={props.escapeInDebounce} when={props.escapeInDebounce}
fallback={ fallback={

View File

@@ -1,3 +1,5 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) { export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]` return `[pasted #${value}]`
} }
@@ -7,27 +9,27 @@ export function formatImagePlaceholder(value: string | number) {
} }
export function createPastedPlaceholderRegex() { export function createPastedPlaceholderRegex() {
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi return /\[pasted #(\d+)\]/g
} }
export function createImagePlaceholderRegex() { export function createImagePlaceholderRegex() {
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi return /\[Image #(\d+)\]/g
} }
export function createMentionRegex() { export function createMentionRegex() {
return /@(\S+)/g return /@(\S+)/g
} }
export const pastedDisplayCounterRegex = /pasted #(\d+)/i export const pastedDisplayCounterRegex = /pasted #(\d+)/
export const imageDisplayCounterRegex = /Image #(\d+)/i export const imageDisplayCounterRegex = /Image #(\d+)/
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
export function parseCounter(value: string) { export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed return Number.isNaN(parsed) ? null : parsed
} }
export function findHighestAttachmentCounters(currentPrompt: string) { export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
let highestPaste = 0 let highestPaste = 0
let highestImage = 0 let highestImage = 0
@@ -38,6 +40,27 @@ export function findHighestAttachmentCounters(currentPrompt: string) {
} }
} }
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1]) const parsed = parseCounter(match[1])
if (parsed !== null) { if (parsed !== null) {

View File

@@ -8,7 +8,6 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi { export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void focus(): void
} }
@@ -17,12 +16,6 @@ export interface PromptInputProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
sessionId: string sessionId: string
// Used to scope global "type-to-focus" behavior.
isActive?: boolean
// Phone/tablet layouts should keep the expanded prompt more compact.
compactLayout?: boolean
onSend: (prompt: string, attachments: Attachment[]) => Promise<void> onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void> onRunShell?: (command: string) => Promise<void>
disabled?: boolean disabled?: boolean

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, type Accessor } from "solid-js" import { createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment" import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
@@ -7,7 +7,6 @@ import {
findHighestAttachmentCounters, findHighestAttachmentCounters,
formatImagePlaceholder, formatImagePlaceholder,
formatPastedPlaceholder, formatPastedPlaceholder,
imageDisplayCounterRegex,
pastedDisplayCounterRegex, pastedDisplayCounterRegex,
} from "./attachmentPlaceholders" } from "./attachmentPlaceholders"
@@ -24,7 +23,7 @@ type PromptAttachments = {
attachments: Accessor<Attachment[]> attachments: Accessor<Attachment[]>
pasteCount: Accessor<number> pasteCount: Accessor<number>
imageCount: Accessor<number> imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string) => void syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
handlePaste: (e: ClipboardEvent) => Promise<void> handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean> isDragging: Accessor<boolean>
@@ -42,106 +41,45 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const [pasteCount, setPasteCount] = createSignal(0) const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string) { function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt) const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
setPasteCount(highestPaste) setPasteCount(highestPaste)
setImageCount(highestImage) setImageCount(highestImage)
} }
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
const next = currentPrompt.replace(tokenRegex, "")
if (next === currentPrompt) return currentPrompt
return next
.replace(/[ \t]{2,}/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.trim()
}
const createLooseImagePlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
// Keep placeholder-backed attachments in sync with prompt text.
// If the placeholder token disappears from the prompt, the attachment should disappear too.
createEffect(() => {
const currentPrompt = options.prompt()
const currentAttachments = attachments()
const toRemove: string[] = []
for (const attachment of currentAttachments) {
if (attachment.source.type === "text") {
const match = attachment.display.match(pastedDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
continue
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const match =
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
if (!match) continue
const counter = match[1]
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
toRemove.push(attachment.id)
}
}
}
for (const attachmentId of toRemove) {
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
}
})
function handleRemoveAttachment(attachmentId: string) { function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments() const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId) const attachment = currentAttachments.find((a) => a.id === attachmentId)
// Always remove from store.
removeAttachment(options.instanceId(), options.sessionId(), attachmentId) removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (!attachment) return if (attachment) {
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
const currentPrompt = options.prompt() if (attachment.source.type === "file") {
let nextPrompt = currentPrompt if (attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (attachment.source.type === "file") { if (imageMatch) {
if (attachment.mediaType.startsWith("image/")) { const placeholder = formatImagePlaceholder(imageMatch[1])
const imageMatch = newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex) }
if (imageMatch) { } else {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1])) const filename = attachment.filename
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
} }
} else { } else if (attachment.source.type === "agent") {
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`. const agentName = attachment.filename
const candidates = [attachment.source.path, attachment.filename] newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
for (const candidate of candidates) { } else if (attachment.source.type === "text") {
if (!candidate) continue const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i") if (placeholderMatch) {
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex) const placeholder = formatPastedPlaceholder(placeholderMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
} }
} }
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
}
}
if (nextPrompt !== currentPrompt) { options.setPrompt(newPrompt)
options.setPrompt(nextPrompt)
} }
} }
@@ -205,32 +143,13 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const blob = item.getAsFile() const blob = item.getAsFile()
if (!blob) continue if (!blob) continue
const { highestImage } = findHighestAttachmentCounters(options.prompt()) const count = imageCount() + 1
const count = highestImage + 1
setImageCount(count) setImageCount(count)
const placeholder = formatImagePlaceholder(count)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
}
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1] const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png` const filename = `image-${count}.png`
const attachment = createFileAttachment( const attachment = createFileAttachment(
@@ -241,8 +160,24 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
options.instanceFolder(), options.instanceFolder(),
) )
attachment.url = `data:image/png;base64,${base64Data}` attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = placeholder attachment.display = display
addAttachment(options.instanceId(), options.sessionId(), attachment) addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
} }
reader.readAsDataURL(blob) reader.readAsDataURL(blob)
@@ -261,8 +196,7 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
if (isLongPaste) { if (isLongPaste) {
e.preventDefault() e.preventDefault()
const { highestPaste } = findHighestAttachmentCounters(options.prompt()) const count = pasteCount() + 1
const count = highestPaste + 1
setPasteCount(count) setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
@@ -270,12 +204,14 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
const filename = `paste-${count}.txt` const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename) const attachment = createTextAttachment(pastedText, display, filename)
const placeholder = formatPastedPlaceholder(count) addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea() const textarea = options.getTextarea()
if (textarea) { if (textarea) {
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const currentText = options.prompt() const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText) options.setPrompt(newText)
@@ -284,11 +220,7 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus() textarea.focus()
}, 0) }, 0)
} else {
options.setPrompt(options.prompt() + placeholder)
} }
addAttachment(options.instanceId(), options.sessionId(), attachment)
} }
} }

View File

@@ -183,25 +183,9 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments() const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find((a) => { const attachment = currentAttachments.find(
if (a.source.type === "agent") { (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
return a.filename === name )
}
if (a.source.type === "file") {
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
return (
a.filename === name ||
a.source.path === name ||
a.source.path.endsWith("/" + name) ||
a.source.path === name.replace(/\/$/, "")
)
}
if (a.source.type === "text") {
// For text attachments (path-only mentions), match by value
return a.source.value === name || a.source.value.endsWith("/" + name)
}
return false
})
if (attachment) { if (attachment) {
e.preventDefault() e.preventDefault()
@@ -221,14 +205,6 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart) textarea.setSelectionRange(mentionStart, mentionStart)
}, 0) }, 0)
// Check if there are any @ remaining in the text - if not, close the picker
if (!newText.includes("@") && options.isPickerOpen()) {
options.closePicker()
// Clear ignoredAtPositions since we deleted the entire @mention
// This ensures typing @ again will open the picker
options.setIgnoredAtPositions(new Set())
}
return return
} }
} }

View File

@@ -1,10 +1,9 @@
import { createSignal, type Accessor, type Setter } from "solid-js" import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session" import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment" import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments" import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types" import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker"
type PickerItem = type PickerItem =
| { type: "agent"; agent: Agent } | { type: "agent"; agent: Agent }
@@ -38,7 +37,7 @@ type PromptPickerController = {
setIgnoredAtPositions: Setter<Set<number>> setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void handlePickerSelect: (item: PickerItem) => void
handlePickerClose: () => void handlePickerClose: () => void
} }
@@ -104,11 +103,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
setAtPosition(null) setAtPosition(null)
} }
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) { function handlePickerSelect(item: PickerItem) {
const textarea = options.getTextarea() const textarea = options.getTextarea()
if (item.type === "command") { if (item.type === "command") {
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
const name = item.command.name const name = item.command.name
const currentPrompt = options.prompt() const currentPrompt = options.prompt()
@@ -130,7 +128,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
} }
}, 0) }, 0)
} else if (item.type === "agent") { } else if (item.type === "agent") {
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
const agentName = item.agent.name const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some( const alreadyAttached = existingAttachments.some(
@@ -166,151 +163,75 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const relativePath = item.file.relativePath ?? displayPath const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition() const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0 const cursorPos = textarea?.selectionStart || 0
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => { if (pos !== null) {
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos) const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos) const after = currentPrompt.substring(cursorPos)
const suffix = opts?.trailingSpace ? " " : "" const attachmentText = `@${normalizedPath}`
const nextPrompt = before + mentionText + suffix + after const newPrompt = before + attachmentText + " " + after
options.setPrompt(nextPrompt) options.setPrompt(newPrompt)
setTimeout(() => { setTimeout(() => {
const nextTextarea = options.getTextarea() const nextTextarea = options.getTextarea()
if (!nextTextarea) return if (nextTextarea) {
const nextCursorPos = pos + mentionText.length + suffix.length const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos) nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}, 0)
}
const replaceMentionQueryAfterAt = (value: string) => {
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const nextPrompt = before + value + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
const nextCursorPos = pos + 1 + value.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const folderMention =
relativePath === "." || relativePath === "" || relativePath === "./"
? "./"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "")
// If it's root "./", just return "./"
if (trimmed === "" || trimmed === ".") return "./"
// Otherwise remove any leading ./ and add ./ prefix
return "./" + trimmed.replace(/^\.\//, "")
})()
const addPathOnlyAttachment = (value: string) => {
const display = `path: ${value}`
const filename = value
const existing = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existing.some(
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
)
if (!alreadyAttached) {
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
}
}
if (isFolder) {
if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents.
replaceMentionQueryAfterAt(folderMention)
setSearchQuery(folderMention)
return
}
const mentionText = `@${folderMention}`
if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
replaceMentionToken(mentionText, { trailingSpace: true })
} else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedFolderPath,
dirFilename,
"inode/directory",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
} }
}, 0)
replaceMentionToken(mentionText, { trailingSpace: true })
}
} else {
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
if (action === "tab") {
// TAB on file: autocomplete the file path but do not attach.
replaceMentionToken(`@${normalizedPath}`)
setSearchQuery(normalizedPath)
return
}
if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
addPathOnlyAttachment(normalizedPathWithPrefix)
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
} else {
// ENTER/click on file: attach file (existing behavior).
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPathWithPrefix,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
}
} }
} }
@@ -324,28 +245,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition() const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) { if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos)) setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
} }
setShowPicker(false) setShowPicker(false)
setAtPosition(null) setAtPosition(null)

View File

@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli" import { restartCli } from "../lib/native/cli"
import { serverSettings, setListeningMode } from "../stores/preferences" import { preferences, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts" import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
@@ -23,7 +23,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null) const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({}) const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null) const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
@@ -34,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [savingPassword, setSavingPassword] = createSignal(false) const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? []) const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all") const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => { const displayAddresses = createMemo(() => {
const list = addresses() const list = addresses()
@@ -89,10 +88,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return return
} }
if (applyingListeningMode()) {
return
}
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"), title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning", variant: "warning",
@@ -105,21 +100,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return return
} }
setApplyingListeningMode(true) setListeningMode(targetMode)
setError(null) const restarted = await restartCli()
try { if (!restarted) {
// Important: await the config patch before restart so Electron reads the updated mode from disk. setError(t("remoteAccess.restart.errorManual"))
await setListeningMode(targetMode) } else {
const restarted = await restartCli() setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
} }
void refreshMeta() void refreshMeta()
@@ -210,7 +196,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
onChange={(nextChecked) => { onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked) void handleAllowConnectionsChange(nextChecked)
}} }}
disabled={loading() || applyingListeningMode()}
> >
<Switch.Input /> <Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}> <Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>

View File

@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</span> </span>
</Show> </Show>
</div> </div>
<kbd class="kbd ml-2 kbd-hint"> <kbd class="kbd ml-2">
Cmd+Enter Cmd+Enter
</kbd> </kbd>
</button> </button>

View File

@@ -10,7 +10,6 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
@@ -29,8 +28,6 @@ interface SessionViewProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
escapeInDebounce: boolean escapeInDebounce: boolean
isPhoneLayout?: boolean
compactPromptLayout?: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
@@ -56,22 +53,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 +67,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
if (!props.isActive) return if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom() scheduleScrollToBottom()
}) })
@@ -90,9 +76,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => { (isActive) => {
if (!isActive) return if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
if (props.isPhoneLayout) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.) // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null const activeEl = document.activeElement as HTMLElement | null
@@ -237,35 +220,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 +278,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()
}
} }
}} }}
@@ -348,34 +299,26 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/> />
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<PromptAttachmentsBar <PromptAttachmentsBar
attachments={attachments()} attachments={attachments()}
onRemoveAttachment={(attachmentId) => { onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
if (promptInputApi) { onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
promptInputApi.removeAttachment(attachmentId) />
return </Show>
}
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}
instanceFolder={props.instanceFolder} instanceFolder={props.instanceFolder}
sessionId={activeSession.id} sessionId={activeSession.id}
isActive={props.isActive} onSend={handleSendMessage}
compactLayout={props.compactPromptLayout} onRunShell={handleRunShell}
onSend={handleSendMessage} escapeInDebounce={props.escapeInDebounce}
onRunShell={handleRunShell} isSessionBusy={sessionBusy()}
escapeInDebounce={props.escapeInDebounce} disabled={sessionNeedsInput()}
isSessionBusy={sessionBusy()} onAbortSession={handleAbortSession}
disabled={sessionNeedsInput()} registerPromptInputApi={registerPromptInputApi}
onAbortSession={handleAbortSession} />
registerPromptInputApi={registerPromptInputApi}
/>
</div> </div>
) )
}} }}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,9 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) { if (!trimmed) {
return "" return ""
} }
// Don't normalize "." - it's used for workspace root if (trimmed === "." || trimmed === "./") {
return ""
}
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "") return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
} }
@@ -72,12 +74,10 @@ type PickerItem =
| { type: "file"; file: FileItem } | { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand } | { type: "command"; command: SDKCommand }
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
interface UnifiedPickerProps { interface UnifiedPickerProps {
open: boolean open: boolean
mode?: "mention" | "command" mode?: "mention" | "command"
onSelect: (item: PickerItem, action: PickerSelectAction) => void onSelect: (item: PickerItem) => void
onClose: () => void onClose: () => void
agents: Agent[] agents: Agent[]
commands?: SDKCommand[] commands?: SDKCommand[]
@@ -266,13 +266,6 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery const queryChanged = lastQuery !== props.searchQuery
if (queryChanged) {
// Reset selectedIndex to 0 when query changes to avoid ghost state
// This ensures proper highlighting when navigating back to root or changing queries
setSelectedIndex(0)
resetScrollPosition()
}
if (!isInitialized() || workspaceChanged || queryChanged) { if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true) setIsInitialized(true)
lastWorkspaceId = props.workspaceId lastWorkspaceId = props.workspaceId
@@ -287,14 +280,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase() const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query const filtered = query
? visibleAgents.filter( ? props.agents.filter(
(agent) => (agent) =>
agent.name.toLowerCase().includes(query) || agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)), (agent.description && agent.description.toLowerCase().includes(query)),
) )
: visibleAgents : props.agents
setFilteredAgents(filtered) setFilteredAgents(filtered)
}) })
@@ -349,22 +341,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items return items
} }
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/") filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
if (mode() === "mention" && isExactRootQuery) {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
items.push({ type: "file", file: rootFile })
}
// Don't show agents for exact root path queries
if (!isExactRootQuery) {
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
}
files().forEach((file) => items.push({ type: "file", file })) files().forEach((file) => items.push({ type: "file", file }))
return items return items
} }
@@ -379,7 +356,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
} }
function handleSelect(item: PickerItem) { function handleSelect(item: PickerItem) {
props.onSelect(item, "click") props.onSelect(item)
} }
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@@ -402,8 +379,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
e.stopPropagation() e.stopPropagation()
const selected = items[selectedIndex()] const selected = items[selectedIndex()]
if (selected) { if (selected) {
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" handleSelect(selected)
props.onSelect(selected, action)
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -467,7 +443,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div <div
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`} class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={isSelected()} data-picker-selected={isSelected()}
onClick={() => props.onSelect({ type: "command", command }, "click")} onClick={() => handleSelect({ type: "command", command })}
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -488,7 +464,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}> <Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")} {t("unifiedPicker.sections.agents")}
</div> </div>
@@ -503,7 +479,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`} }`}
data-picker-selected={itemIndex === selectedIndex()} data-picker-selected={itemIndex === selectedIndex()}
onClick={() => props.onSelect({ type: "agent", agent }, "click")} onClick={() => handleSelect({ type: "agent", agent })}
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<svg <svg
@@ -543,39 +519,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For> </For>
</Show> </Show>
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}> <Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header"> <div class="dropdown-section-header">
{t("unifiedPicker.sections.files")} {t("unifiedPicker.sections.files")}
</div> </div>
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
<div
class={`dropdown-item py-1.5 ${
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={selectedIndex() === 0}
onClick={() => {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
props.onSelect({ type: "file", file: rootFile }, "click")
}}
>
<div class="flex items-center gap-2 text-sm">
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
</div>
</div>
</Show>
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
const itemIndex = allItems().findIndex( const itemIndex = allItems().findIndex(
@@ -588,7 +535,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : "" itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`} }`}
data-picker-selected={itemIndex === selectedIndex()} data-picker-selected={itemIndex === selectedIndex()}
onClick={() => props.onSelect({ type: "file", file }, "click")} onClick={() => handleSelect({ type: "file", file })}
> >
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<Show <Show

Some files were not shown because too many files have changed in this diff Show More