Compare commits
1 Commits
v0.12.2-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f692c5df |
145
.github/workflows/build-and-upload.yml
vendored
145
.github/workflows/build-and-upload.yml
vendored
@@ -3,11 +3,6 @@ name: Build and Upload Binaries
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages (release builds)"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: false
|
required: false
|
||||||
@@ -50,8 +45,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -61,21 +54,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
shell: bash
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
env:
|
|
||||||
NPM_CONFIG_FETCH_RETRIES: 5
|
|
||||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
|
||||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
@@ -86,112 +65,6 @@ jobs:
|
|||||||
- name: Build macOS binaries (Electron)
|
- name: Build macOS binaries (Electron)
|
||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
release_root="packages/electron-app/release"
|
|
||||||
apps=()
|
|
||||||
while IFS= read -r -d '' app; do
|
|
||||||
apps+=("$app")
|
|
||||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
|
||||||
|
|
||||||
if [ "${#apps[@]}" -eq 0 ]; then
|
|
||||||
echo "No CodeNomad.app found under $release_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub macOS runners typically have no signing identity. Without any signature,
|
|
||||||
# the shipped .app can fail Gatekeeper with:
|
|
||||||
# code has no resources but signature indicates they must be present
|
|
||||||
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
|
||||||
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
|
||||||
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
|
||||||
for app in "${apps[@]}"; do
|
|
||||||
echo "codesign (adhoc): $app"
|
|
||||||
codesign --force --deep --sign - "$app"
|
|
||||||
codesign --verify --deep --strict --verbose=2 "$app"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Repackage Electron macOS zips (ditto)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Prefer the workflow-provided version; fall back to package.json.
|
|
||||||
VERSION_TO_USE="${VERSION:-}"
|
|
||||||
if [ -z "$VERSION_TO_USE" ]; then
|
|
||||||
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_root="packages/electron-app/release"
|
|
||||||
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
|
||||||
# Use find to locate built app bundles instead of ** globs.
|
|
||||||
apps=()
|
|
||||||
while IFS= read -r -d '' app; do
|
|
||||||
apps+=("$app")
|
|
||||||
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
|
||||||
if [ "${#apps[@]}" -eq 0 ]; then
|
|
||||||
echo "No CodeNomad.app found under $release_root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for app in "${apps[@]}"; do
|
|
||||||
bundle_dir=$(basename "$(dirname "$app")")
|
|
||||||
arch="x64"
|
|
||||||
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
|
||||||
rm -f "$out_zip"
|
|
||||||
echo "ditto -ck: $app -> $out_zip"
|
|
||||||
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Validate Electron macOS codesign (unzipped)
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
shopt -s nullglob
|
|
||||||
|
|
||||||
tmp_dir=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
|
||||||
|
|
||||||
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
|
||||||
if [ "${#zips[@]}" -eq 0 ]; then
|
|
||||||
echo "No Electron macOS zip artifacts found to validate" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for zip in "${zips[@]}"; do
|
|
||||||
echo "Validating codesign for: $zip"
|
|
||||||
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
|
||||||
mkdir -p "$extract_dir"
|
|
||||||
|
|
||||||
# Use ditto for extraction as well to preserve bundle metadata.
|
|
||||||
ditto -x -k "$zip" "$extract_dir"
|
|
||||||
|
|
||||||
app_path=""
|
|
||||||
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
|
||||||
if [ -d "$candidate" ]; then
|
|
||||||
app_path="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$app_path" ]; then
|
|
||||||
echo "No .app found after extracting $zip" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -212,8 +85,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -253,8 +124,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -295,8 +164,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -370,8 +237,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -445,8 +310,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -525,8 +388,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -629,8 +490,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -728,8 +587,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
80
.github/workflows/dev-release.yml
vendored
80
.github/workflows/dev-release.yml
vendored
@@ -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
|
||||||
|
|||||||
40
.github/workflows/manual-npm-publish.yml
vendored
40
.github/workflows/manual-npm-publish.yml
vendored
@@ -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
|
|
||||||
|
|||||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -1,13 +1,7 @@
|
|||||||
name: Release UI
|
name: Release UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call: {}
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -24,8 +18,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
35
.github/workflows/reusable-release.yml
vendored
35
.github/workflows/reusable-release.yml
vendored
@@ -3,11 +3,6 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -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
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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`.
|
||||||
|
|
||||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
|
||||||
|
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1,4 +1 @@
|
|||||||
public/
|
public/
|
||||||
|
|
||||||
# Local developer config (may contain secrets)
|
|
||||||
config-*.json
|
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
192
packages/server/src/config/binaries.ts
Normal file
192
packages/server/src/config/binaries.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
78
packages/server/src/config/store.ts
Normal file
78
packages/server/src/config/store.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "/"
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
62
packages/server/src/server/routes/config.ts
Normal file
62
packages/server/src/server/routes/config.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
20
packages/tauri-app/Cargo.lock
generated
20
packages/tauri-app/Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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("")
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()}`}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const globalCache = cacheHandle.get<RenderCache>()
|
const globalCache = cacheHandle.get<RenderCache>()
|
||||||
if (globalCache && cacheMatches(globalCache)) {
|
if (globalCache && cacheMatches(globalCache)) {
|
||||||
setHtml(globalCache.html)
|
setHtml(globalCache.html)
|
||||||
|
part.renderCache = globalCache
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,11 +100,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const commitCacheEntry = (renderedHtml: string) => {
|
const commitCacheEntry = (renderedHtml: string) => {
|
||||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||||
setHtml(renderedHtml)
|
setHtml(renderedHtml)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
if (!highlightEnabled) {
|
||||||
|
part.renderCache = undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
|
|
||||||
@@ -181,6 +185,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
@@ -197,15 +202,5 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
const proseClass = () => "markdown-body"
|
||||||
|
|
||||||
return (
|
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
class={proseClass()}
|
|
||||||
data-view="markdown"
|
|
||||||
data-part-id={resolved().partId}
|
|
||||||
data-markdown-theme={resolved().themeKey}
|
|
||||||
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
|
|
||||||
innerHTML={html()}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
|
|
||||||
|
|
||||||
export function getMessageAnchorId(messageId: string) {
|
|
||||||
return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMessageIdFromAnchorId(anchorId: string) {
|
|
||||||
return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
|
|
||||||
}
|
|
||||||
64
packages/ui/src/components/message-block-list.tsx
Normal file
64
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Index, type Accessor } from "solid-js"
|
||||||
|
import VirtualItem from "./virtual-item"
|
||||||
|
import MessageBlock from "./message-block"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
export function getMessageAnchorId(messageId: string) {
|
||||||
|
return `message-anchor-${messageId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
|
||||||
|
interface MessageBlockListProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
messageIds: () => string[]
|
||||||
|
lastAssistantIndex: () => number
|
||||||
|
showThinking: () => boolean
|
||||||
|
thinkingDefaultExpanded: () => boolean
|
||||||
|
showUsageMetrics: () => boolean
|
||||||
|
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||||
|
loading?: boolean
|
||||||
|
onRevert?: (messageId: string) => void
|
||||||
|
onFork?: (messageId?: string) => void
|
||||||
|
onContentRendered?: () => void
|
||||||
|
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||||
|
suspendMeasurements?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Index each={props.messageIds()}>
|
||||||
|
{(messageId, index) => (
|
||||||
|
<VirtualItem
|
||||||
|
id={getMessageAnchorId(messageId())}
|
||||||
|
cacheKey={messageId()}
|
||||||
|
scrollContainer={props.scrollContainer}
|
||||||
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
|
placeholderClass="message-stream-placeholder"
|
||||||
|
virtualizationEnabled={() => !props.loading}
|
||||||
|
suspendMeasurements={props.suspendMeasurements}
|
||||||
|
>
|
||||||
|
<MessageBlock
|
||||||
|
messageId={messageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageIndex={index}
|
||||||
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
|
showThinking={props.showThinking}
|
||||||
|
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||||
|
showUsageMetrics={props.showUsageMetrics}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</VirtualItem>
|
||||||
|
)}
|
||||||
|
</Index>
|
||||||
|
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -12,17 +12,8 @@ import { formatTokenTotal } from "../lib/formatters"
|
|||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessagePart } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
|
||||||
return (
|
|
||||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
|
||||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
@@ -32,10 +23,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
|||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
|
||||||
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||||
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||||
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||||
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||||
|
|
||||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||||
return Boolean(state && state.status === "running")
|
return Boolean(state && state.status === "running")
|
||||||
@@ -203,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">
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
|
|
||||||
interface MessagePreviewProps {
|
interface MessagePreviewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
messageId: string
|
messageId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
deleteHover?: () => DeleteHoverState
|
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
||||||
selectedMessageIds?: () => Set<string>
|
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||||
@@ -30,11 +24,6 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
|||||||
showThinking={() => false}
|
showThinking={() => false}
|
||||||
thinkingDefaultExpanded={() => false}
|
thinkingDefaultExpanded={() => false}
|
||||||
showUsageMetrics={() => false}
|
showUsageMetrics={() => false}
|
||||||
deleteHover={props.deleteHover}
|
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||||
import MessagePreview from "./message-preview"
|
import MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
|
|
||||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||||
|
|
||||||
@@ -21,38 +19,18 @@ export interface TimelineSegment {
|
|||||||
shortLabel?: string
|
shortLabel?: string
|
||||||
variant?: "auto" | "manual"
|
variant?: "auto" | "manual"
|
||||||
toolPartIds?: string[]
|
toolPartIds?: string[]
|
||||||
partIds?: string[]
|
|
||||||
partId?: string
|
|
||||||
totalChars: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageTimelineProps {
|
interface MessageTimelineProps {
|
||||||
segments: TimelineSegment[]
|
segments: TimelineSegment[]
|
||||||
onSegmentClick?: (segment: TimelineSegment) => void
|
onSegmentClick?: (segment: TimelineSegment) => void
|
||||||
onToggleSelection?: (id: string) => void
|
activeMessageId?: string | null
|
||||||
onLongPressSelection?: (segment: TimelineSegment) => void
|
|
||||||
onSelectRange?: (id: string) => void
|
|
||||||
onClearSelection?: () => void
|
|
||||||
selectedIds?: Accessor<Set<string>>
|
|
||||||
expandedMessageIds?: Accessor<Set<string>>
|
|
||||||
// Optional: restrict histogram/xray overlay to only show for these message ids.
|
|
||||||
// Used to hide ribs for messages before the last compaction.
|
|
||||||
deletableMessageIds?: Accessor<Set<string>>
|
|
||||||
activeSegmentId?: string | null
|
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
showToolSegments?: boolean
|
showToolSegments?: boolean
|
||||||
deleteHover?: () => DeleteHoverState
|
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
||||||
selectedMessageIds?: () => Set<string>
|
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TOOLTIP_LENGTH = 220
|
const MAX_TOOLTIP_LENGTH = 220
|
||||||
const LONG_PRESS_MS = 500
|
|
||||||
const JITTER_THRESHOLD = 10
|
|
||||||
const ABSOLUTE_TOKEN_CAP = 10000
|
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -60,8 +38,10 @@ interface PendingSegment {
|
|||||||
type: TimelineSegmentType
|
type: TimelineSegmentType
|
||||||
texts: string[]
|
texts: string[]
|
||||||
reasoningTexts: string[]
|
reasoningTexts: string[]
|
||||||
partIds: string[]
|
toolTitles: string[]
|
||||||
totalChars: number
|
toolTypeLabels: string[]
|
||||||
|
toolIcons: string[]
|
||||||
|
toolPartIds: string[]
|
||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +171,18 @@ export function buildTimelineSegments(
|
|||||||
pending = null
|
pending = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const label = segmentLabel(pending.type)
|
const isToolSegment = pending.type === "tool"
|
||||||
const shortLabel = undefined
|
const label = isToolSegment
|
||||||
const tooltip = formatTextsTooltip(
|
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||||
[...pending.texts, ...pending.reasoningTexts],
|
: segmentLabel(pending.type)
|
||||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||||
)
|
const tooltip = isToolSegment
|
||||||
|
? formatToolTooltip(pending.toolTitles, t)
|
||||||
|
: formatTextsTooltip(
|
||||||
|
[...pending.texts, ...pending.reasoningTexts],
|
||||||
|
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||||
|
)
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
@@ -205,24 +190,16 @@ export function buildTimelineSegments(
|
|||||||
label,
|
label,
|
||||||
tooltip,
|
tooltip,
|
||||||
shortLabel,
|
shortLabel,
|
||||||
partIds: pending.partIds,
|
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||||
totalChars: pending.totalChars,
|
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||||
if (!pending || pending.type !== type) {
|
if (!pending || pending.type !== type) {
|
||||||
flushPending()
|
flushPending()
|
||||||
pending = {
|
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||||
type,
|
|
||||||
texts: [],
|
|
||||||
reasoningTexts: [],
|
|
||||||
partIds: [],
|
|
||||||
totalChars: 0,
|
|
||||||
hasPrimaryText: type !== "assistant",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pending!
|
return pending!
|
||||||
}
|
}
|
||||||
@@ -234,21 +211,14 @@ export function buildTimelineSegments(
|
|||||||
if (!part || typeof part !== "object") continue
|
if (!part || typeof part !== "object") continue
|
||||||
|
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
flushPending()
|
const target = ensureSegment("tool")
|
||||||
const toolPart = part as ToolCallPart
|
const toolPart = part as ToolCallPart
|
||||||
const partId = typeof toolPart.id === "string" ? toolPart.id : ""
|
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||||
const title = getToolTitle(toolPart, t)
|
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||||
result.push({
|
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||||
id: `${record.id}:${segmentIndex}`,
|
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||||
messageId: record.id,
|
target.toolPartIds.push(toolPart.id)
|
||||||
type: "tool",
|
}
|
||||||
label: getToolTypeLabel(toolPart, t) || segmentLabel("tool"),
|
|
||||||
tooltip: formatToolTooltip([title], t),
|
|
||||||
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
|
|
||||||
toolPartIds: partId ? [partId] : undefined,
|
|
||||||
totalChars: getPartCharCount(part),
|
|
||||||
})
|
|
||||||
segmentIndex += 1
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,18 +228,13 @@ export function buildTimelineSegments(
|
|||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.reasoningTexts.push(text)
|
target.reasoningTexts.push(text)
|
||||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
|
||||||
target.partIds.push((part as any).id)
|
|
||||||
}
|
|
||||||
target.totalChars += getPartCharCount(part)
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "compaction") {
|
if (part.type === "compaction") {
|
||||||
flushPending()
|
flushPending()
|
||||||
const isAuto = Boolean((part as any)?.auto)
|
const isAuto = Boolean((part as any)?.auto)
|
||||||
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
|
|
||||||
result.push({
|
result.push({
|
||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
@@ -277,8 +242,6 @@ export function buildTimelineSegments(
|
|||||||
label: segmentLabel("compaction"),
|
label: segmentLabel("compaction"),
|
||||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||||
variant: isAuto ? "auto" : "manual",
|
variant: isAuto ? "auto" : "manual",
|
||||||
partId,
|
|
||||||
totalChars: 0,
|
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
continue
|
continue
|
||||||
@@ -287,23 +250,19 @@ export function buildTimelineSegments(
|
|||||||
if (part.type === "step-start" || part.type === "step-finish") {
|
if (part.type === "step-start" || part.type === "step-finish") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = collectTextFromPart(part, t)
|
const text = collectTextFromPart(part, t)
|
||||||
if (text.trim().length === 0) continue
|
if (text.trim().length === 0) continue
|
||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.texts.push(text)
|
target.texts.push(text)
|
||||||
target.hasPrimaryText = true
|
target.hasPrimaryText = true
|
||||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
|
||||||
target.partIds.push((part as any).id)
|
|
||||||
}
|
|
||||||
target.totalChars += getPartCharCount(part)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
flushPending()
|
flushPending()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,14 +278,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
let hoverTimer: number | null = null
|
let hoverTimer: number | null = null
|
||||||
let closeTimer: number | null = null
|
let closeTimer: number | null = null
|
||||||
const showTools = () => props.showToolSegments ?? true
|
const showTools = () => props.showToolSegments ?? true
|
||||||
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
|
|
||||||
|
|
||||||
const isHistogramEligible = (segment: TimelineSegment): boolean => {
|
|
||||||
const allowed = props.deletableMessageIds?.()
|
|
||||||
if (!allowed) return true
|
|
||||||
return allowed.has(segment.messageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
buttonRefs.set(segmentId, element)
|
buttonRefs.set(segmentId, element)
|
||||||
@@ -334,7 +286,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
buttonRefs.delete(segmentId)
|
buttonRefs.delete(segmentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearHoverTimer = () => {
|
const clearHoverTimer = () => {
|
||||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||||
window.clearTimeout(hoverTimer)
|
window.clearTimeout(hoverTimer)
|
||||||
@@ -360,11 +312,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
setHoverAnchorRect(null)
|
setHoverAnchorRect(null)
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||||
// Suppress previews during long-press selection gestures.
|
|
||||||
if (longPressTimer !== null) return
|
|
||||||
|
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
clearCloseTimer()
|
clearCloseTimer()
|
||||||
@@ -379,7 +328,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
scheduleClose()
|
scheduleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
const anchor = hoverAnchorRect()
|
const anchor = hoverAnchorRect()
|
||||||
@@ -401,235 +350,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
clearCloseTimer()
|
clearCloseTimer()
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Selection & histogram rib state ---
|
|
||||||
const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0)
|
|
||||||
|
|
||||||
// Segments eligible for xray ribs. We intentionally exclude messages before
|
|
||||||
// the last compaction (when provided by the parent) to avoid misleading token
|
|
||||||
// weights for content that's no longer in context.
|
|
||||||
const xraySegments = createMemo(() => {
|
|
||||||
if (!isSelectionActive()) return [] as TimelineSegment[]
|
|
||||||
return props.segments.filter((segment) => isHistogramEligible(segment))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Stable layout offsets per badge (relative to scroll content), recomputed only
|
|
||||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
|
||||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
|
||||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
|
||||||
let xrayOverlayRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
// Full layout recomputation: reads every badge's getBoundingClientRect once,
|
|
||||||
// then stores offsets relative to the scroll content so they survive scrolling.
|
|
||||||
const computeBadgeLayout = () => {
|
|
||||||
if (!isSelectionActive() || !scrollContainerRef) return
|
|
||||||
const containerRect = scrollContainerRef.getBoundingClientRect()
|
|
||||||
const scrollTop = scrollContainerRef.scrollTop
|
|
||||||
const offsets: Record<string, { layoutTop: number; height: number }> = {}
|
|
||||||
|
|
||||||
for (const [id, element] of buttonRefs.entries()) {
|
|
||||||
if (!element) continue
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
// Store position relative to scroll content (survives scrolling).
|
|
||||||
offsets[id] = {
|
|
||||||
layoutTop: rect.top - containerRect.top + scrollTop,
|
|
||||||
height: rect.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setBadgeOffsets(offsets)
|
|
||||||
if (xrayOverlayRef) {
|
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
setWindowWidth(window.innerWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!isSelectionActive()) return
|
|
||||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isSelectionActive()) {
|
const activeId = props.activeMessageId
|
||||||
computeBadgeLayout()
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
// Deferred pass: tool segments become visible when selection activates,
|
|
||||||
// but they may need a layout pass before getBoundingClientRect is accurate.
|
|
||||||
requestAnimationFrame(computeBadgeLayout)
|
|
||||||
window.addEventListener("resize", computeBadgeLayout)
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("resize", computeBadgeLayout)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Re-compute badge layout after expansion changes (tools become visible in DOM)
|
|
||||||
createEffect(() => {
|
|
||||||
props.expandedMessageIds?.()
|
|
||||||
if (isSelectionActive()) {
|
|
||||||
requestAnimationFrame(computeBadgeLayout)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
|
||||||
|
|
||||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
|
||||||
// tool parts whose output arrived after the timeline segment was first built.
|
|
||||||
const liveSegmentChars = createMemo(() => {
|
|
||||||
if (!isSelectionActive()) return {} as Record<string, number>
|
|
||||||
const result: Record<string, number> = {}
|
|
||||||
const resolvedStore = store()
|
|
||||||
|
|
||||||
// Compute live char counts by reading only the parts that the segment
|
|
||||||
// references (partIds/toolPartIds). This stays accurate for streamed tool
|
|
||||||
// outputs without scanning every part in the message.
|
|
||||||
for (const segment of xraySegments()) {
|
|
||||||
const record = resolvedStore.getMessage(segment.messageId)
|
|
||||||
if (!record) {
|
|
||||||
result[segment.id] = segment.totalChars
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])]
|
|
||||||
let chars = 0
|
|
||||||
for (const partId of ids) {
|
|
||||||
const part = record.parts?.[partId]?.data
|
|
||||||
if (!part) continue
|
|
||||||
chars += getPartCharCount(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
result[segment.id] = chars > 0 ? chars : segment.totalChars
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup.
|
|
||||||
// Avoids the previous O(n²) pattern of iterating all segments inside each <For> item.
|
|
||||||
const aggregateTokensByMessageId = createMemo(() => {
|
|
||||||
const chars = liveSegmentChars()
|
|
||||||
const result: Record<string, number> = {}
|
|
||||||
for (const s of xraySegments()) {
|
|
||||||
result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars)
|
|
||||||
}
|
|
||||||
for (const id of Object.keys(result)) {
|
|
||||||
result[id] = Math.max(Math.round(result[id] / 4), 1)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const getSegmentTokens = (segment: TimelineSegment): number => {
|
|
||||||
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
|
|
||||||
// When tools are hidden (not expanded, not in selection mode), assistant/user
|
|
||||||
// bars show aggregate tokens for the whole message. When tools are visible
|
|
||||||
// (expanded or selection mode active), each segment shows its own tokens to
|
|
||||||
// avoid double-counting.
|
|
||||||
if (!isExpanded && !isSelectionActive() && (segment.type === "assistant" || segment.type === "user")) {
|
|
||||||
return aggregateTokensByMessageId()[segment.messageId] ?? 1
|
|
||||||
}
|
|
||||||
const chars = liveSegmentChars()[segment.id] ?? segment.totalChars
|
|
||||||
return Math.max(Math.round(chars / 4), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMessageAggregateTokens = (messageId: string): number => {
|
|
||||||
return aggregateTokensByMessageId()[messageId] ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTokenLabel = (tokens: number): string => {
|
|
||||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
|
||||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
|
||||||
return String(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxTokens = createMemo(() => {
|
|
||||||
let max = 0
|
|
||||||
for (const s of xraySegments()) {
|
|
||||||
const tokens = getSegmentTokens(s)
|
|
||||||
if (tokens > max) max = tokens
|
|
||||||
}
|
|
||||||
return Math.max(max, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Long-press for mobile selection ---
|
|
||||||
let longPressTimer: number | null = null
|
|
||||||
let wasLongPress = false
|
|
||||||
let pressStartPos = { x: 0, y: 0 }
|
|
||||||
|
|
||||||
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
|
|
||||||
if (event.button !== 0) return
|
|
||||||
wasLongPress = false
|
|
||||||
pressStartPos = { x: event.clientX, y: event.clientY }
|
|
||||||
|
|
||||||
clearHoverTimer()
|
|
||||||
clearCloseTimer()
|
|
||||||
|
|
||||||
if (longPressTimer !== null && typeof window !== "undefined") {
|
|
||||||
window.clearTimeout(longPressTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
longPressTimer = window.setTimeout(() => {
|
|
||||||
longPressTimer = null
|
|
||||||
wasLongPress = true
|
|
||||||
|
|
||||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
|
||||||
const btn = buttonRefs.get(segment.id)
|
|
||||||
let anchorOffset: number | null = null
|
|
||||||
if (btn && scrollContainerRef) {
|
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.onLongPressSelection) {
|
|
||||||
props.onLongPressSelection(segment)
|
|
||||||
} else {
|
|
||||||
props.onToggleSelection?.(segment.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
|
||||||
const desired = btn.offsetTop - anchorOffset
|
|
||||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
|
||||||
scrollContainerRef.scrollTop = desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, LONG_PRESS_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerUp = () => {
|
|
||||||
if (longPressTimer !== null && typeof window !== "undefined") {
|
|
||||||
window.clearTimeout(longPressTimer)
|
|
||||||
longPressTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
|
||||||
if (longPressTimer !== null) {
|
|
||||||
const dist = Math.sqrt(
|
|
||||||
Math.pow(event.clientX - pressStartPos.x, 2) +
|
|
||||||
Math.pow(event.clientY - pressStartPos.y, 2),
|
|
||||||
)
|
|
||||||
if (dist > JITTER_THRESHOLD) {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.clearTimeout(longPressTimer)
|
|
||||||
}
|
|
||||||
longPressTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContextMenu = (event: MouseEvent) => {
|
|
||||||
if (wasLongPress) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
const element = buttonRefs.get(activeId)
|
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
||||||
|
if (!targetSegment) return
|
||||||
|
const element = buttonRefs.get(targetSegment.id)
|
||||||
if (!element) return
|
if (!element) return
|
||||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
@@ -639,7 +366,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}))
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const element = tooltipElement()
|
const element = tooltipElement()
|
||||||
@@ -656,265 +383,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const previewData = createMemo(() => {
|
const previewData = createMemo(() => {
|
||||||
|
|
||||||
const segment = hoveredSegment()
|
const segment = hoveredSegment()
|
||||||
if (!segment) return null
|
if (!segment) return null
|
||||||
const record = store().getMessage(segment.messageId)
|
const record = store().getMessage(segment.messageId)
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
return { messageId: segment.messageId }
|
return { messageId: segment.messageId }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pre-computed set of messageIds that have at least one tool segment.
|
|
||||||
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
|
|
||||||
const messagesWithTools = createMemo(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
for (const s of props.segments) {
|
|
||||||
if (s.type === "tool") set.add(s.messageId)
|
|
||||||
}
|
|
||||||
return set
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pre-computed index map for session message ordering.
|
|
||||||
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
|
|
||||||
const messageIdToSessionIndex = createMemo(() => {
|
|
||||||
const ids = store().getSessionMessageIds(props.sessionId)
|
|
||||||
const map = new Map<string, number>()
|
|
||||||
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline-container">
|
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
||||||
<div
|
<For each={props.segments}>
|
||||||
ref={scrollContainerRef}
|
{(segment) => {
|
||||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
role="navigation"
|
const isActive = () => props.activeMessageId === segment.messageId
|
||||||
aria-label={t("messageTimeline.ariaLabel")}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
<For each={props.segments}>
|
|
||||||
{(segment, segIndex) => {
|
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
|
||||||
const isActive = () => props.activeSegmentId === segment.id
|
|
||||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
|
||||||
|
|
||||||
const isDeleteHovered = () => {
|
const hasActivePermission = () => {
|
||||||
const hover = deleteHover() as DeleteHoverState
|
if (segment.type !== "tool") return false
|
||||||
if (hover.kind === "message") {
|
const partIds = segment.toolPartIds ?? []
|
||||||
return hover.messageId === segment.messageId
|
if (partIds.length === 0) return false
|
||||||
}
|
for (const partId of partIds) {
|
||||||
|
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||||
if (hover.kind === "deleteUpTo") {
|
if (permissionState?.active) return true
|
||||||
const indexMap = messageIdToSessionIndex()
|
|
||||||
const targetIndex = indexMap.get(hover.messageId)
|
|
||||||
if (targetIndex === undefined) return false
|
|
||||||
const segmentIndex = indexMap.get(segment.messageId)
|
|
||||||
if (segmentIndex === undefined) return false
|
|
||||||
return segmentIndex >= targetIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const isDeleteSelected = () => {
|
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
|
||||||
const selected = props.selectedMessageIds?.()
|
|
||||||
if (!selected) return false
|
|
||||||
return selected.has(segment.messageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActivePermission = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type !== "tool") return false
|
if (segment.type === "tool") {
|
||||||
const partIds = segment.toolPartIds ?? []
|
if (hasActivePermission()) {
|
||||||
if (partIds.length === 0) return false
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
for (const partId of partIds) {
|
|
||||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
|
||||||
if (permissionState?.active) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
|
||||||
const isHidden = () =>
|
|
||||||
segment.type === "tool" &&
|
|
||||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
|
||||||
|
|
||||||
// Group visual indicators: tools belong to the same message as their
|
|
||||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
|
||||||
const groupRole = (): "child" | "parent" | "none" => {
|
|
||||||
if (segment.type === "tool") return "child"
|
|
||||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
const isGroupStart = () => {
|
|
||||||
if (segment.type !== "tool") return false
|
|
||||||
const idx = segIndex()
|
|
||||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
|
||||||
// First tool in the message's run: either nothing before, or previous
|
|
||||||
// segment is from a different message or is not a tool.
|
|
||||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortLabelContent = () => {
|
|
||||||
if (segment.type === "tool") {
|
|
||||||
if (hasActivePermission()) {
|
|
||||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
|
||||||
}
|
}
|
||||||
if (segment.type === "compaction") {
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
if (segment.type === "user") {
|
|
||||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
}
|
||||||
|
if (segment.type === "compaction") {
|
||||||
|
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
if (segment.type === "user") {
|
||||||
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
type="button"
|
type="button"
|
||||||
data-variant={segment.variant}
|
data-variant={segment.variant}
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||||
|
|
||||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
aria-current={isActive() ? "true" : undefined}
|
||||||
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
aria-current={isActive() ? "true" : undefined}
|
onClick={() => props.onSegmentClick?.(segment)}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
onClick={(event) => {
|
onMouseLeave={handleMouseLeave}
|
||||||
if (wasLongPress) {
|
>
|
||||||
wasLongPress = false
|
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||||
return
|
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||||
}
|
</button>
|
||||||
|
)
|
||||||
// Capture scroll anchor before selection changes may toggle
|
}}
|
||||||
// tool segment visibility, which shifts timeline layout.
|
</For>
|
||||||
const btn = buttonRefs.get(segment.id)
|
<Show when={previewData()}>
|
||||||
let anchorOffset: number | null = null
|
{(data) => {
|
||||||
if (btn && scrollContainerRef) {
|
onCleanup(() => setTooltipElement(null))
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
return (
|
||||||
}
|
<div
|
||||||
|
ref={(element) => setTooltipElement(element)}
|
||||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
class="message-timeline-tooltip"
|
||||||
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
if (event.shiftKey) {
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
props.onSelectRange?.(segment.id)
|
onMouseLeave={() => scheduleClose()}
|
||||||
} else if (event.ctrlKey || event.metaKey) {
|
>
|
||||||
props.onToggleSelection?.(segment.id)
|
<MessagePreview
|
||||||
} else if (isMultiSelectActive) {
|
messageId={data().messageId}
|
||||||
// In selection mode, plain click scrolls to the message
|
instanceId={props.instanceId}
|
||||||
// instead of clearing. Selection is cleared by clicking
|
sessionId={props.sessionId}
|
||||||
// anywhere inside the chat container or pressing Esc.
|
store={store}
|
||||||
props.onSegmentClick?.(segment)
|
/>
|
||||||
} else {
|
</div>
|
||||||
props.onSegmentClick?.(segment)
|
)
|
||||||
}
|
}}
|
||||||
|
|
||||||
// Restore scroll anchor: keep the clicked badge at the same
|
|
||||||
// visual position after hidden tools appear or disappear.
|
|
||||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
|
||||||
const desired = btn.offsetTop - anchorOffset
|
|
||||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
|
||||||
scrollContainerRef.scrollTop = desired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
|
||||||
onPointerUp={handlePointerUp}
|
|
||||||
onPointerCancel={handlePointerUp}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
|
||||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={previewData()}>
|
|
||||||
{(data) => {
|
|
||||||
onCleanup(() => setTooltipElement(null))
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(element) => setTooltipElement(element)}
|
|
||||||
class="message-timeline-tooltip"
|
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
|
||||||
onMouseEnter={() => clearCloseTimer()}
|
|
||||||
onMouseLeave={() => scheduleClose()}
|
|
||||||
>
|
|
||||||
<MessagePreview
|
|
||||||
messageId={data().messageId}
|
|
||||||
instanceId={props.instanceId}
|
|
||||||
sessionId={props.sessionId}
|
|
||||||
store={store}
|
|
||||||
deleteHover={props.deleteHover}
|
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={isSelectionActive()}>
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
xrayOverlayRef = el
|
|
||||||
if (xrayOverlayRef && scrollContainerRef) {
|
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class="message-timeline-xray-overlay"
|
|
||||||
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
|
|
||||||
>
|
|
||||||
<div class="message-timeline-xray-overlay-inner">
|
|
||||||
<For each={xraySegments()}>
|
|
||||||
{(segment) => {
|
|
||||||
const pos = () => {
|
|
||||||
const offset = badgeOffsets()[segment.id]
|
|
||||||
if (!offset) return null
|
|
||||||
return { top: offset.layoutTop + offset.height / 2 }
|
|
||||||
}
|
|
||||||
const tokens = () => getSegmentTokens(segment)
|
|
||||||
const relativeWeight = () => tokens() / maxTokens()
|
|
||||||
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
|
|
||||||
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
|
|
||||||
const isParent = segment.type === "assistant" || segment.type === "user"
|
|
||||||
const displayTokens = () =>
|
|
||||||
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
|
|
||||||
return (
|
|
||||||
<Show when={pos()}>
|
|
||||||
<div
|
|
||||||
class="message-timeline-xray-rib"
|
|
||||||
style={{
|
|
||||||
top: `${pos()!.top}px`,
|
|
||||||
left: "var(--xray-overhang)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="message-timeline-xray-token-label">
|
|
||||||
{formatTokenLabel(displayTokens())}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="message-timeline-relative-bar"
|
|
||||||
style={{ "--segment-weight": relativeWeight() }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
|
|
||||||
style={{ "--segment-weight": absoluteWeight() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MessageTimeline
|
export default MessageTimeline
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||||
import { tGlobal } from "../../lib/i18n"
|
import { tGlobal } from "../../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { TextPart } from "../../types/message"
|
import type { TextPart } from "../../types/message"
|
||||||
import { Markdown } from "../markdown"
|
import { Markdown } from "../markdown"
|
||||||
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
import { resolveTitleForTool } from "../tool-title"
|
import { resolveTitleForTool } from "../tool-title"
|
||||||
@@ -178,116 +178,28 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
void loadMessages(instanceId, id)
|
void loadMessages(instanceId, id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [childToolKeys, setChildToolKeys] = createSignal<string[]>([])
|
const childToolKeys = createMemo(() => {
|
||||||
|
|
||||||
let indexedSessionId = ""
|
|
||||||
let indexedMessageCount = 0
|
|
||||||
let indexedMessageTail = ""
|
|
||||||
const indexedPartCounts = new Map<string, number>()
|
|
||||||
|
|
||||||
function resetChildToolIndex(nextSessionId: string) {
|
|
||||||
indexedSessionId = nextSessionId
|
|
||||||
indexedMessageCount = 0
|
|
||||||
indexedMessageTail = ""
|
|
||||||
indexedPartCounts.clear()
|
|
||||||
setChildToolKeys([])
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanMessageToolParts(messageId: string, startIndex: number) {
|
|
||||||
const record = store.getMessage(messageId)
|
|
||||||
if (!record) return [] as string[]
|
|
||||||
|
|
||||||
const partIds = record.partIds
|
|
||||||
const keys: string[] = []
|
|
||||||
for (let idx = startIndex; idx < partIds.length; idx += 1) {
|
|
||||||
const partId = partIds[idx]
|
|
||||||
const entry = record.parts?.[partId]
|
|
||||||
const data = entry?.data
|
|
||||||
if (!data || (data as any).type !== "tool") continue
|
|
||||||
keys.push(`${messageId}::${partId}`)
|
|
||||||
}
|
|
||||||
indexedPartCounts.set(messageId, partIds.length)
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
|
|
||||||
indexedSessionId = sessionId
|
|
||||||
indexedMessageCount = messageIds.length
|
|
||||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
|
||||||
indexedPartCounts.clear()
|
|
||||||
|
|
||||||
const nextKeys: string[] = []
|
|
||||||
for (const messageId of messageIds) {
|
|
||||||
nextKeys.push(...scanMessageToolParts(messageId, 0))
|
|
||||||
}
|
|
||||||
setChildToolKeys(nextKeys)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const id = childSessionId()
|
const id = childSessionId()
|
||||||
const loaded = childSessionLoaded()
|
if (!id) return [] as string[]
|
||||||
|
if (!childSessionLoaded()) return [] as string[]
|
||||||
|
|
||||||
if (!id || !loaded) {
|
// React to session changes, but do the scan untracked to avoid
|
||||||
if (indexedSessionId) {
|
// subscribing to every message/part node in the store.
|
||||||
resetChildToolIndex("")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// We use the session revision as the reactive change point, but avoid
|
|
||||||
// rescanning the entire session on every update.
|
|
||||||
store.getSessionRevision(id)
|
store.getSessionRevision(id)
|
||||||
|
return untrack(() => {
|
||||||
untrack(() => {
|
|
||||||
const messageIds = store.getSessionMessageIds(id)
|
const messageIds = store.getSessionMessageIds(id)
|
||||||
|
const keys: string[] = []
|
||||||
if (!indexedSessionId || indexedSessionId !== id) {
|
for (const messageId of messageIds) {
|
||||||
fullRescanChildTools(id, messageIds)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
|
|
||||||
if (messageIds.length < indexedMessageCount) {
|
|
||||||
fullRescanChildTools(id, messageIds)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (indexedMessageCount > 0) {
|
|
||||||
const expectedTailIndex = indexedMessageCount - 1
|
|
||||||
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
|
|
||||||
fullRescanChildTools(id, messageIds)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendedKeys: string[] = []
|
|
||||||
|
|
||||||
// Scan any new messages appended since last index.
|
|
||||||
for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) {
|
|
||||||
const messageId = messageIds[idx]
|
|
||||||
appendedKeys.push(...scanMessageToolParts(messageId, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan a small window of recent messages for newly appended parts.
|
|
||||||
// Deltas typically affect the most recent tool call, so this avoids
|
|
||||||
// iterating every message on every revision.
|
|
||||||
const existingCount = Math.min(indexedMessageCount, messageIds.length)
|
|
||||||
const windowStart = Math.max(0, existingCount - 3)
|
|
||||||
for (let idx = windowStart; idx < existingCount; idx += 1) {
|
|
||||||
const messageId = messageIds[idx]
|
|
||||||
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
|
|
||||||
const record = store.getMessage(messageId)
|
const record = store.getMessage(messageId)
|
||||||
const nextPartCount = record?.partIds.length ?? 0
|
if (!record) continue
|
||||||
if (nextPartCount > previousPartCount) {
|
for (const partId of record.partIds) {
|
||||||
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount))
|
const entry = record.parts?.[partId]
|
||||||
|
const data = entry?.data
|
||||||
|
if (!data || (data as any).type !== "tool") continue
|
||||||
|
keys.push(`${messageId}::${partId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return keys
|
||||||
indexedMessageCount = messageIds.length
|
|
||||||
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
|
||||||
|
|
||||||
if (appendedKeys.length > 0) {
|
|
||||||
setChildToolKeys((prev) => [...prev, ...appendedKeys])
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const promptContent = createMemo(() => {
|
const promptContent = createMemo(() => {
|
||||||
@@ -375,9 +287,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
content: promptContent()!,
|
content: promptContent()!,
|
||||||
cacheKey: "task:prompt",
|
cacheKey: "task:prompt",
|
||||||
disableScrollTracking: true,
|
disableScrollTracking: true,
|
||||||
// Always use the normal markdown render path for prompt (even while running)
|
disableHighlight: true,
|
||||||
// so the prompt doesn't visually change between running/completed states.
|
|
||||||
disableHighlight: false,
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -442,7 +352,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<For each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { readToolStatePayload } from "../utils"
|
import { readToolStatePayload } from "../utils"
|
||||||
import { useI18n, tGlobal } from "../../../lib/i18n"
|
import { useI18n, tGlobal } from "../../../lib/i18n"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||||
import { enMessages } from "../../lib/i18n/messages/en"
|
import { enMessages } from "../../lib/i18n/messages/en"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
|
|
||||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||||
import { getLanguageFromPath } from "../../lib/markdown"
|
import { getLanguageFromPath } from "../../lib/markdown"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { tGlobal } from "../../lib/i18n"
|
import { tGlobal } from "../../lib/i18n"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
export type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||||
export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||||
export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||||
|
|
||||||
export const diffCapableTools = new Set(["edit", "patch"])
|
export const diffCapableTools = new Set(["edit", "patch"])
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user