Compare commits
5 Commits
ready/ui-m
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1f702597 | ||
|
|
2d93d82611 | ||
|
|
4e0f064c3a | ||
|
|
e4e10cc630 | ||
|
|
8f6d4c8b09 |
240
.github/workflows/build-and-upload.yml
vendored
240
.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
|
||||||
@@ -28,21 +23,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
upload_actions_artifacts:
|
|
||||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
actions_artifacts_retention_days:
|
|
||||||
description: "Retention (days) for GitHub Actions artifacts"
|
|
||||||
required: false
|
|
||||||
default: 7
|
|
||||||
type: number
|
|
||||||
actions_artifacts_name_prefix:
|
|
||||||
description: "Optional prefix for Actions artifact names"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -65,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
|
||||||
@@ -76,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
|
||||||
@@ -101,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: |
|
||||||
@@ -218,15 +76,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -236,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
|
||||||
@@ -268,15 +115,6 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -286,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
|
||||||
@@ -319,15 +155,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -337,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
|
||||||
@@ -381,7 +206,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -392,15 +217,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -421,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
|
||||||
@@ -465,7 +279,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -476,15 +290,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -505,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
|
||||||
@@ -552,7 +355,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -565,15 +368,6 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -594,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
|
||||||
@@ -651,7 +443,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -677,15 +469,6 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
|
||||||
path: packages/tauri-app/release-tauri/*
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -707,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
|
||||||
@@ -806,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
|
||||||
@@ -844,12 +623,3 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
|
||||||
path: packages/electron-app/release/*.rpm
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
121
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -1,121 +0,0 @@
|
|||||||
name: Comment PR Artifacts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
RETENTION_DAYS: 7
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Wait for PR build and comment
|
|
||||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const prNumber = Number(process.env.PR_NUMBER);
|
|
||||||
const headSha = process.env.HEAD_SHA;
|
|
||||||
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
|
||||||
const marker = '<!-- codenomad-pr-artifacts -->';
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
let matchedRun = null;
|
|
||||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
||||||
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
workflow_id: 'pr-build.yml',
|
|
||||||
event: 'pull_request',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matchingRuns = runs
|
|
||||||
.filter((run) => run.head_sha === headSha)
|
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
|
|
||||||
matchedRun = matchingRuns[0] || null;
|
|
||||||
if (matchedRun && matchedRun.status === 'completed') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
|
||||||
await sleep(10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedRun) {
|
|
||||||
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedRun.status !== 'completed') {
|
|
||||||
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const artifacts = await github.paginate(
|
|
||||||
github.rest.actions.listWorkflowRunArtifacts,
|
|
||||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
|
||||||
);
|
|
||||||
const active = artifacts.filter((artifact) => !artifact.expired);
|
|
||||||
|
|
||||||
const runUrl = matchedRun.html_url;
|
|
||||||
const artifactsBlock = active.length
|
|
||||||
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
|
||||||
: 'Artifacts: (none found on this run)';
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
marker,
|
|
||||||
'PR builds are available as GitHub Actions artifacts:',
|
|
||||||
'',
|
|
||||||
runUrl,
|
|
||||||
'',
|
|
||||||
`Artifacts expire in ${retentionDays} days.`,
|
|
||||||
artifactsBlock,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const created = await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: prNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
|
||||||
61
.github/workflows/dev-release.yml
vendored
61
.github/workflows/dev-release.yml
vendored
@@ -1,13 +1,12 @@
|
|||||||
name: Develop Pre-Release
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
push:
|
||||||
# Nightly build of dev (only if dev has new commits)
|
branches:
|
||||||
- cron: "0 1 * * *"
|
- dev
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -16,63 +15,25 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gate:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
run: ${{ steps.gate.outputs.run }}
|
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
|
||||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Decide whether to run
|
- name: Compute version suffix
|
||||||
id: gate
|
id: vars
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
SHA8="${GITHUB_SHA::8}"
|
||||||
api() {
|
|
||||||
curl -sS \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
|
||||||
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
|
||||||
echo "Failed to resolve dev head SHA" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATE=$(date -u +%Y%m%d)
|
DATE=$(date -u +%Y%m%d)
|
||||||
SHA8="${DEV_SHA::8}"
|
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||||
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
|
||||||
|
|
||||||
SHOULD_RUN="false"
|
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
else
|
|
||||||
# Nightly: only run if dev has advanced since last successful dev-release build.
|
|
||||||
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
|
||||||
if [ -z "${LAST_SHA}" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
|
||||||
SHOULD_RUN="true"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
prerelease:
|
prerelease:
|
||||||
needs: gate
|
needs: prepare
|
||||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
|
||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
|
||||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
6
.github/workflows/manual-npm-publish.yml
vendored
6
.github/workflows/manual-npm-publish.yml
vendored
@@ -19,10 +19,6 @@ on:
|
|||||||
type: string
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version:
|
version:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -50,8 +46,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
57
.github/workflows/pr-build.yml
vendored
57
.github/workflows/pr-build.yml
vendored
@@ -1,57 +0,0 @@
|
|||||||
name: PR Build Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-build-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
authorize:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: authorize
|
|
||||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
upload: false
|
|
||||||
upload_actions_artifacts: true
|
|
||||||
actions_artifacts_retention_days: 7
|
|
||||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
|
||||||
set_versions: false
|
|
||||||
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
|
||||||
|
|||||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
54
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Restrict Non-Dev PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
restrict-non-dev-prs:
|
|
||||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check allowed actor
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Comment on unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
|
||||||
|
|
||||||
- name: Close unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr close "$PR_NUMBER"
|
|
||||||
|
|
||||||
- name: Fail unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
run: |
|
|
||||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
|
||||||
exit 1
|
|
||||||
11
.github/workflows/reusable-release.yml
vendored
11
.github/workflows/reusable-release.yml
vendored
@@ -3,11 +3,6 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
|
||||||
description: "Git ref (branch, tag, or SHA) to build from"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -51,8 +46,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -91,7 +84,6 @@ jobs:
|
|||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
@@ -103,8 +95,6 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
@@ -113,7 +103,6 @@ jobs:
|
|||||||
- build-and-upload
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
package_name: ${{ inputs.npm_package_name }}
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -44,22 +44,19 @@ 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
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🧪 Dev Releases
|
|
||||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Dev builds are published as GitHub pre-releases:
|
||||||
|
https://github.com/shantur/CodeNomad/releases
|
||||||
|
|
||||||
|
Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch.
|
||||||
|
|
||||||
|
This command starts the server and opens the web client in your default browser.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||||
@@ -123,6 +120,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)
|
|
||||||
|
|
||||||
|
|||||||
96
package-lock.json
generated
96
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"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": {
|
||||||
@@ -3253,9 +3253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3305,32 +3305,6 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
|
||||||
"version": "2.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
|
||||||
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -10244,6 +10218,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/tauri-plugin-keepawake-api": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -10984,36 +10966,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/virtua": {
|
|
||||||
"version": "0.48.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
|
||||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.14.0",
|
|
||||||
"react-dom": ">=16.14.0",
|
|
||||||
"solid-js": ">=1.0",
|
|
||||||
"svelte": ">=5.0",
|
|
||||||
"vue": ">=3.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"solid-js": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"svelte": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12033,7 +11985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12043,7 +11995,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -12070,7 +12021,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12111,7 +12062,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12119,18 +12070,16 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.3",
|
"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",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
@@ -12143,8 +12092,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",
|
||||||
"virtua": "^0.48.8",
|
"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.3",
|
"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.12.3",
|
"minServerVersion": "0.10.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -66,24 +65,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
|
||||||
if (!Array.isArray(paths)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = paths.filter((value): value is string => {
|
|
||||||
if (typeof value !== "string" || value.trim().length === 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return fs.statSync(value).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return directories
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
const mode = parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
@@ -431,9 +431,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||||
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
|
||||||
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
const { contextBridge, ipcRenderer } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,14 +12,6 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
|
||||||
getPathForFile: (file) => {
|
|
||||||
try {
|
|
||||||
return webUtils.getPathForFile(file)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"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",
|
||||||
@@ -45,7 +42,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.24"
|
"@opencode-ai/plugin": "1.1.53"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.3",
|
"version": "0.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"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.3",
|
"version": "0.10.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
244
packages/server/src/config/store.ts
Normal file
244
packages/server/src/config/store.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
import {
|
||||||
|
ConfigFile,
|
||||||
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
|
StateFile,
|
||||||
|
StateFileSchema,
|
||||||
|
} from "./schema"
|
||||||
|
import type { ConfigLocation } from "./location"
|
||||||
|
|
||||||
|
export class ConfigStore {
|
||||||
|
private cache: ConfigFile = DEFAULT_CONFIG
|
||||||
|
private state: StateFile = DEFAULT_STATE
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly location: ConfigLocation,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): ConfigFile {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
const legacyJsonPath = this.location.legacyJsonPath
|
||||||
|
|
||||||
|
if (fs.existsSync(configYamlPath)) {
|
||||||
|
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
|
||||||
|
const stateDoc = fs.existsSync(stateYamlPath)
|
||||||
|
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
|
||||||
|
: DEFAULT_STATE
|
||||||
|
|
||||||
|
this.state = stateDoc
|
||||||
|
this.cache = this.mergeDocs(configDoc, stateDoc)
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
|
||||||
|
} else if (fs.existsSync(legacyJsonPath)) {
|
||||||
|
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
|
||||||
|
this.state = migrated.state
|
||||||
|
this.cache = migrated.config
|
||||||
|
} else {
|
||||||
|
// Fresh install: write defaults.
|
||||||
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
|
this.persist()
|
||||||
|
this.logger.debug(
|
||||||
|
{ configYamlPath, stateYamlPath },
|
||||||
|
"No config files found, created default YAML config/state",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
|
||||||
|
this.state = DEFAULT_STATE
|
||||||
|
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): ConfigFile {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(config: ConfigFile) {
|
||||||
|
const validated = ConfigFileSchema.parse(config)
|
||||||
|
this.commit(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a merge-patch update to the current config.
|
||||||
|
* - Missing keys are preserved.
|
||||||
|
* - Object values are merged recursively.
|
||||||
|
* - Explicit `null` deletes keys.
|
||||||
|
* - Arrays are replaced.
|
||||||
|
*/
|
||||||
|
mergePatch(patch: unknown) {
|
||||||
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||||
|
throw new Error("Config patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current as any, patch as any)
|
||||||
|
const validated = ConfigFileSchema.parse(next)
|
||||||
|
this.commit(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private commit(next: ConfigFile) {
|
||||||
|
this.cache = next
|
||||||
|
this.loaded = true
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
recentFolders: next.recentFolders,
|
||||||
|
}
|
||||||
|
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 configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
|
||||||
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
|
||||||
|
|
||||||
|
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
|
||||||
|
const stateYaml = stringifyYaml(this.state as any)
|
||||||
|
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
|
||||||
|
|
||||||
|
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to persist config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
|
||||||
|
const merged = {
|
||||||
|
...(configDoc as any),
|
||||||
|
// State wins for recent folders.
|
||||||
|
recentFolders: stateDoc.recentFolders ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigFileSchema.parse(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readYamlFile<T>(
|
||||||
|
filePath: string,
|
||||||
|
fallback: T,
|
||||||
|
schema: { parse: (value: unknown) => T },
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
return schema.parse(parsed ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
|
||||||
|
const configYamlPath = this.location.configYamlPath
|
||||||
|
const stateYamlPath = this.location.stateYamlPath
|
||||||
|
|
||||||
|
const content = fs.readFileSync(legacyJsonPath, "utf-8")
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
const legacy = ConfigFileSchema.parse(parsed)
|
||||||
|
|
||||||
|
const state: StateFile = StateFileSchema.parse({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
recentFolders: legacy.recentFolders ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
|
||||||
|
|
||||||
|
// Persist YAML docs first, then move legacy aside.
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.location.baseDir, { recursive: true })
|
||||||
|
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
|
||||||
|
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
|
||||||
|
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bakPath = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bakPath)
|
||||||
|
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: merged, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
|
||||||
|
const clone: Record<string, unknown> = { ...(config as any) }
|
||||||
|
delete clone.recentFolders
|
||||||
|
return clone as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMergePatch(current: any, patch: any): any {
|
||||||
|
// RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = isPlainObject(current) ? { ...current } : {}
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainObject(value) && isPlainObject(base[key])) {
|
||||||
|
base[key] = applyMergePatch(base[key], value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrays and scalars replace.
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(legacyJsonPath: string): string {
|
||||||
|
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
|
||||||
|
const preferred = `${base}.json.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${base}.json.bak.${Date.now()}`
|
||||||
|
}
|
||||||
@@ -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,9 @@ 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 { ConfigStore } from "./config/store"
|
||||||
import { resolveConfigLocation } from "./config/location"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
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"
|
||||||
@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||||
@@ -291,12 +291,21 @@ 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(configLocation, eventBus, configLogger)
|
||||||
const binaryResolver = new BinaryResolver(settings)
|
|
||||||
|
// Eagerly load config at boot so migrations run immediately
|
||||||
|
// (instead of waiting for the first /api/config request).
|
||||||
|
try {
|
||||||
|
configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -383,7 +392,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 +413,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,
|
||||||
|
|||||||
@@ -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())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
76
packages/server/src/server/routes/config.ts
Normal file
76
packages/server/src/server/routes/config.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { ConfigStore } from "../../config/store"
|
||||||
|
import { BinaryRegistry } from "../../config/binaries"
|
||||||
|
|
||||||
|
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, reply) => {
|
||||||
|
// Backwards compatible: treat PUT as a merge-patch update.
|
||||||
|
try {
|
||||||
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/api/config/app", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
deps.configStore.mergePatch(request.body ?? {})
|
||||||
|
return deps.configStore.get()
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid config patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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> {
|
||||||
|
|||||||
2439
packages/tauri-app/Cargo.lock
generated
2439
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const serverDevInstallCommand =
|
|||||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -92,15 +91,6 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncServerUiBundle() {
|
|
||||||
console.log("[prebuild] syncing server public UI bundle...")
|
|
||||||
execSync(serverPrepareUiCommand, {
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: envWithRootBin,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
return
|
return
|
||||||
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
|
|||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.12.3"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -19,12 +19,9 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
keepawake = "0.6"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
"opener:allow-open-url",
|
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ use std::ffi::OsStr;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -19,24 +17,10 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn configure_spawn(command: &mut Command) {
|
|
||||||
command.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn configure_spawn(_command: &mut Command) {}
|
|
||||||
|
|
||||||
fn workspace_root() -> Option<PathBuf> {
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
std::env::current_dir().ok().and_then(|mut dir| {
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -52,46 +36,6 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
|||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn configure_posix_process_group(command: &mut Command) {
|
|
||||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
|
||||||
// processes (login shell/tsx) without leaving the server orphaned.
|
|
||||||
unsafe {
|
|
||||||
command.pre_exec(|| {
|
|
||||||
if libc::setpgid(0, 0) != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
|
||||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
|
||||||
if force {
|
|
||||||
args.push("/F".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut command = Command::new("taskkill");
|
|
||||||
command.args(&args);
|
|
||||||
configure_spawn(&mut command);
|
|
||||||
|
|
||||||
match command.output() {
|
|
||||||
Ok(output) => {
|
|
||||||
if output.status.success() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the PID is already gone, treat it as success.
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
|
||||||
let combined = format!("{stdout}\n{stderr}");
|
|
||||||
combined.contains("not found") || combined.contains("no running instance")
|
|
||||||
}
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
let mut display = url.to_string();
|
let mut display = url.to_string();
|
||||||
@@ -196,16 +140,9 @@ 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_locations() -> (PathBuf, PathBuf) {
|
||||||
@@ -251,18 +188,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
|
|
||||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_yaml::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();
|
||||||
}
|
}
|
||||||
@@ -276,17 +206,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
// Legacy fallback.
|
// Legacy fallback.
|
||||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
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();
|
||||||
}
|
}
|
||||||
@@ -404,19 +328,11 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
// Prefer signaling the process group to avoid orphaning children
|
|
||||||
// when the CLI was launched via a wrapper shell.
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), false) {
|
let _ = child.kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -432,17 +348,11 @@ impl CliProcessManager {
|
|||||||
));
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
let _ = child.kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -520,12 +430,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
ShellCommandType::Direct(cmd) => {
|
ShellCommandType::Direct(cmd) => {
|
||||||
@@ -535,12 +442,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -613,24 +517,7 @@ impl CliProcessManager {
|
|||||||
locked.error = Some("CLI did not start in time".to_string());
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
log_line("timeout waiting for CLI readiness");
|
log_line("timeout waiting for CLI readiness");
|
||||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
#[cfg(unix)]
|
let _ = child.kill();
|
||||||
unsafe {
|
|
||||||
let pid = child.id() as i32;
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
Self::emit_status(&app_clone, &locked);
|
Self::emit_status(&app_clone, &locked);
|
||||||
@@ -921,31 +808,14 @@ impl CliEntry {
|
|||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
std::env::var("ELECTRON_RENDERER_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
|
||||||
let log_level = std::env::var("CLI_LOG_LEVEL")
|
|
||||||
.ok()
|
|
||||||
.map(|value| value.trim().to_lowercase())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.unwrap_or_else(|| "info".to_string());
|
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("false".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http-port".to_string());
|
|
||||||
args.push("0".to_string());
|
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push(ui_dev_server);
|
args.push("http://localhost:3000".to_string());
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push(log_level);
|
args.push("debug".to_string());
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
@@ -1010,11 +880,6 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
@@ -1110,18 +975,9 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: PathBuf) -> String {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
let resolved = if let Ok(clean) = path.canonicalize() {
|
if let Ok(clean) = path.canonicalize() {
|
||||||
clean
|
clean.to_string_lossy().to_string()
|
||||||
} else {
|
} else {
|
||||||
path
|
path.to_string_lossy().to_string()
|
||||||
};
|
|
||||||
|
|
||||||
let rendered = resolved.to_string_lossy().to_string();
|
|
||||||
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
|
||||||
format!("\\\\{}", stripped)
|
|
||||||
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
|
||||||
stripped.to_string()
|
|
||||||
} else {
|
|
||||||
rendered
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -15,31 +12,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::ffi::OsStr;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::iter;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[derive(Clone)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
struct WakeLockConfig {
|
|
||||||
display: bool,
|
|
||||||
idle: bool,
|
|
||||||
sleep: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -58,38 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_start(
|
|
||||||
state: tauri::State<AppState>,
|
|
||||||
config: Option<WakeLockConfig>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let config = config.unwrap_or(WakeLockConfig {
|
|
||||||
display: true,
|
|
||||||
idle: false,
|
|
||||||
sleep: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut builder = keepawake::Builder::default();
|
|
||||||
builder
|
|
||||||
.display(config.display)
|
|
||||||
.idle(config.idle)
|
|
||||||
.sleep(config.sleep)
|
|
||||||
.reason("CodeNomad active session")
|
|
||||||
.app_name("CodeNomad")
|
|
||||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
|
||||||
|
|
||||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
*state_lock = Some(wake_lock);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
state_lock.take();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
@@ -101,10 +46,7 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// This must be treated as an internal origin or the navigation guard will
|
||||||
// redirect it to the system browser and the app will appear blank.
|
// redirect it to the system browser and the app will appear blank.
|
||||||
"http" | "https" => matches!(
|
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||||
url.host_str(),
|
|
||||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
|
||||||
),
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,55 +66,6 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
|
||||||
paths
|
|
||||||
.iter()
|
|
||||||
.filter_map(|path| match std::fs::metadata(path) {
|
|
||||||
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
|
||||||
let _ = window.emit(event_name, ());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_folder_drop_event(
|
|
||||||
app_handle: &AppHandle,
|
|
||||||
window_label: &str,
|
|
||||||
event_name: &str,
|
|
||||||
paths: &[std::path::PathBuf],
|
|
||||||
) {
|
|
||||||
let directories = collect_directory_paths(paths);
|
|
||||||
|
|
||||||
if directories.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
|
||||||
let _ = window.emit(event_name, json!({ "paths": directories }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn set_windows_app_user_model_id() {
|
|
||||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
|
||||||
.encode_wide()
|
|
||||||
.chain(iter::once(0))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
|
||||||
if result < 0 {
|
|
||||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn set_windows_app_user_model_id() {}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -181,14 +74,13 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -200,12 +92,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||||
cli_get_status,
|
|
||||||
cli_restart,
|
|
||||||
wake_lock_start,
|
|
||||||
wake_lock_stop
|
|
||||||
])
|
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
// File menu
|
// File menu
|
||||||
@@ -300,27 +187,6 @@ fn main() {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
|
||||||
label,
|
|
||||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
|
||||||
}
|
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
@@ -368,16 +234,13 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N"),
|
Some("CmdOrCtrl+N")
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
.item(&new_instance_item)
|
.item(&new_instance_item)
|
||||||
.separator()
|
.separator()
|
||||||
.text(
|
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||||
if is_mac { "close" } else { "quit" },
|
|
||||||
if is_mac { "Close" } else { "Quit" },
|
|
||||||
)
|
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
@@ -400,6 +263,7 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.text("force_reload", "Force Reload")
|
.text("force_reload", "Force Reload")
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
|
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -413,10 +277,7 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||||
.iter()
|
|
||||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
|
||||||
.collect();
|
|
||||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.12.3",
|
"version": "0.1.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.opencode.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.3",
|
"version": "0.10.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,15 +13,13 @@
|
|||||||
"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",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
@@ -32,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",
|
||||||
"virtua": "^0.48.8",
|
"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,23 +1,21 @@
|
|||||||
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"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -52,18 +50,16 @@ import {
|
|||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
import { openSettings } from "./stores/settings-screen"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
|
const { isDark } = useTheme()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
serverSettings,
|
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
@@ -72,112 +68,26 @@ 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 [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(() => {
|
createEffect(() => {
|
||||||
const active = browserFullscreenActive()
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
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(() => {
|
||||||
@@ -233,26 +143,60 @@ 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()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
|
setIsAdvancedSettingsOpen(false)
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
instanceId,
|
instanceId,
|
||||||
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)
|
||||||
@@ -265,7 +209,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
openSettings("opencode")
|
setIsAdvancedSettingsOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -349,7 +293,6 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
@@ -357,7 +300,6 @@ const App: Component = () => {
|
|||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
setToolInputsVisibility,
|
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
@@ -453,61 +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)}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|
||||||
@@ -523,25 +441,41 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<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()}
|
||||||
onClose={() => {
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
setShowFolderSelection(false)
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
clearLaunchError()
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<SettingsScreen />
|
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
import { escapeHtml } from "../lib/text-render-utils"
|
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -145,8 +141,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
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
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
import { ErrorBoundary } from "solid-js"
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
import { getLanguageFromPath } from "../lib/markdown"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
|
|||||||
@@ -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,37 +1,36 @@
|
|||||||
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 DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onClose?: () => void
|
advancedSettingsOpen?: boolean
|
||||||
|
onAdvancedSettingsOpen?: () => void
|
||||||
|
onAdvancedSettingsClose?: () => void
|
||||||
|
onOpenRemoteAccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = 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))
|
||||||
})
|
})
|
||||||
@@ -193,31 +192,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function dropTargetBlocked() {
|
|
||||||
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
|
||||||
}
|
|
||||||
|
|
||||||
function showInvalidFolderDropAlert() {
|
|
||||||
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
|
||||||
title: t("folderSelection.drop.invalidTitle"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const folderDrop = useFolderDrop({
|
|
||||||
enabled: () => !dropTargetBlocked(),
|
|
||||||
onInvalidDrop: showInvalidFolderDropAlert,
|
|
||||||
onDrop: async (paths) => {
|
|
||||||
const firstPath = paths[0]
|
|
||||||
if (!firstPath) {
|
|
||||||
showInvalidFolderDropAlert()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleFolderSelect(firstPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -235,6 +209,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openExternalLink = (url: string) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -257,6 +236,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleFolderSelect(path)
|
handleFolderSelect(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBinaryChange(binary: string) {
|
||||||
|
|
||||||
|
setSelectedBinary(binary)
|
||||||
|
}
|
||||||
|
|
||||||
function handleRemove(path: string, e?: Event) {
|
function handleRemove(path: string, e?: Event) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -332,10 +316,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
onDragEnter={folderDrop.bind.onDragEnter}
|
|
||||||
onDragOver={folderDrop.bind.onDragOver}
|
|
||||||
onDragLeave={folderDrop.bind.onDragLeave}
|
|
||||||
onDrop={folderDrop.bind.onDrop}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
@@ -386,33 +366,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||||
<button
|
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||||
type="button"
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
|
||||||
onClick={() => openSettings("appearance")}
|
|
||||||
aria-label={t("settings.open.title")}
|
|
||||||
title={t("settings.open.title")}
|
|
||||||
>
|
|
||||||
<Settings class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
|
||||||
onClick={() => openSettings("remote")}
|
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
|
||||||
title={t("instanceTabs.remote.title")}
|
|
||||||
>
|
|
||||||
<MonitorUp class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<Show when={props.onClose}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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.onClose?.()}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
aria-label={t("app.launchError.close")}
|
|
||||||
title={t("app.launchError.closeTitle")}
|
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +384,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
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"
|
||||||
@@ -431,13 +392,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.github")}
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitHubMarkIcon class="w-4 h-4" />
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
@@ -445,7 +406,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.githubStars")}
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Star class="w-4 h-4" />
|
<Star class="w-4 h-4" />
|
||||||
@@ -454,7 +415,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DISCORD_URL}
|
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
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"
|
||||||
@@ -462,7 +423,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.discord")}
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
openExternalLink(
|
||||||
|
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
@@ -585,16 +548,16 @@ 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>
|
||||||
|
|
||||||
{/* OpenCode settings section */}
|
{/* Advanced settings section */}
|
||||||
<div class="panel-section w-full">
|
<div class="panel-section w-full">
|
||||||
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
</button>
|
</button>
|
||||||
@@ -610,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">
|
||||||
@@ -628,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>
|
||||||
@@ -644,17 +607,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
|
||||||
<div class="folder-drop-overlay" aria-hidden="true">
|
|
||||||
<div class="folder-drop-card">
|
|
||||||
<FolderPlus class="w-8 h-8 icon-muted" />
|
|
||||||
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
|
||||||
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AdvancedSettingsModal
|
||||||
|
open={Boolean(props.advancedSettingsOpen)}
|
||||||
|
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||||
|
selectedBinary={selectedBinary()}
|
||||||
|
onBinaryChange={handleBinaryChange}
|
||||||
|
isLoading={props.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title={t("folderSelection.dialog.title")}
|
title={t("folderSelection.dialog.title")}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface HintRowProps {
|
|||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
|
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { openSettings } from "../stores/settings-screen"
|
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -16,11 +17,13 @@ interface InstanceTabsProps {
|
|||||||
onSelect: (instanceId: string) => void
|
onSelect: (instanceId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (instanceId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
|
onOpenRemoteAccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||||
|
|
||||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
@@ -30,10 +33,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const notificationTitle = createMemo(() => {
|
const notificationTitle = createMemo(() => {
|
||||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
if (!notificationsSupported()) return "Notifications unsupported"
|
||||||
return notificationsEnabled()
|
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||||
? t("settings.notifications.status.enabled")
|
|
||||||
: t("settings.notifications.status.disabled")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,35 +72,32 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<ThemeModeToggle class="new-tab-button" />
|
||||||
class="new-tab-button"
|
|
||||||
onClick={() => openSettings("appearance")}
|
|
||||||
title={t("settings.open.title")}
|
|
||||||
aria-label={t("settings.open.ariaLabel")}
|
|
||||||
>
|
|
||||||
<Settings class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
onClick={() => openSettings("notifications")}
|
onClick={() => setNotificationsOpen(true)}
|
||||||
title={notificationTitle()}
|
title={notificationTitle()}
|
||||||
aria-label={notificationTitle()}
|
aria-label={notificationTitle()}
|
||||||
>
|
>
|
||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||||
class="new-tab-button tab-remote-button"
|
<button
|
||||||
onClick={() => openSettings("remote")}
|
class="new-tab-button tab-remote-button"
|
||||||
title={t("instanceTabs.remote.title")}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
title={t("instanceTabs.remote.title")}
|
||||||
>
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
<MonitorUp class="w-4 h-4" />
|
>
|
||||||
</button>
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,12 +1,10 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, 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 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"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -20,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
|
||||||
@@ -34,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
|
||||||
@@ -53,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>
|
||||||
@@ -115,23 +95,14 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
scopeKey={scopeKey}
|
||||||
<div class="file-viewer-empty">
|
path={String(file().file || "")}
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
before={String((file() as any).before || "")}
|
||||||
</div>
|
after={String((file() as any).after || "")}
|
||||||
}
|
viewMode={props.diffViewMode()}
|
||||||
>
|
contextMode={props.diffContextMode()}
|
||||||
<LazyMonacoDiffViewer
|
/>
|
||||||
scopeKey={scopeKey()}
|
|
||||||
path={String(file().file || "")}
|
|
||||||
before={String((file() as any).before || "")}
|
|
||||||
after={String((file() as any).after || "")}
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrap={props.diffWordWrapMode()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,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())
|
||||||
}}
|
}}
|
||||||
@@ -168,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)
|
||||||
}}
|
}}
|
||||||
@@ -193,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={
|
||||||
@@ -203,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 }}
|
||||||
@@ -230,7 +192,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
overlayAriaLabel="Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||||
|
|
||||||
const LazyMonacoFileViewer = lazy(() =>
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
const emptyViewerMessage = () => {
|
||||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
return "Select a file to preview"
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<Suspense
|
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
fallback={
|
|
||||||
<div class="file-viewer-empty">
|
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.browserLoading() && entriesValue === null}>
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={sorted}>
|
<For each={sorted}>
|
||||||
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.browserLoading()}>
|
<Show when={props.browserLoading()}>
|
||||||
<span>{props.t("instanceInfo.loading")}</span>
|
<span>Loading…</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
overlayAriaLabel="Files"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, 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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -31,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
|
||||||
@@ -48,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
|
||||||
@@ -67,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 props.t("instanceShell.sessionChanges.noSessionSelected")
|
|
||||||
const currentEntries = entries()
|
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
|
||||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
|
||||||
})
|
|
||||||
|
|
||||||
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()}
|
||||||
@@ -106,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,
|
||||||
}
|
}
|
||||||
@@ -124,23 +115,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
scopeKey={props.scopeKey()}
|
||||||
<div class="file-viewer-empty">
|
path={String(file().path || "")}
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
before={String((file() as any).before || "")}
|
||||||
</div>
|
after={String((file() as any).after || "")}
|
||||||
}
|
viewMode={props.diffViewMode()}
|
||||||
>
|
contextMode={props.diffContextMode()}
|
||||||
<LazyMonacoDiffViewer
|
/>
|
||||||
scopeKey={props.scopeKey()}
|
|
||||||
path={String(file().path || "")}
|
|
||||||
before={String((file() as any).before || "")}
|
|
||||||
after={String((file() as any).after || "")}
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrap={props.diffWordWrapMode()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -154,7 +136,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,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" : ""}`}
|
||||||
@@ -179,7 +161,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -196,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" : ""}`}
|
||||||
@@ -210,7 +192,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -227,19 +209,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</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>
|
||||||
@@ -249,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()}
|
||||||
@@ -274,7 +247,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
overlayAriaLabel="Git Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
type MarkdownModule = typeof import("../lib/markdown")
|
|
||||||
|
|
||||||
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
|
||||||
|
|
||||||
function loadMarkdownModule(): Promise<MarkdownModule> {
|
|
||||||
if (!markdownModulePromise) {
|
|
||||||
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
|
||||||
markdownModulePromise = null
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return markdownModulePromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
|
||||||
if (partId) {
|
|
||||||
return partId
|
|
||||||
}
|
|
||||||
|
|
||||||
return `anonymous:${hashText(text)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHtmlEntitiesLocally(content: string): string {
|
|
||||||
if (!content.includes("&") || typeof document === "undefined") {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.innerHTML = content
|
|
||||||
return textarea.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(content: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFallbackHtml(content: string): string {
|
|
||||||
if (!content) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return escapeHtml(content).replace(/\n/g, "<br />")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestKey = ""
|
let latestRequestedText = ""
|
||||||
let cleanupLanguageListener: (() => void) | undefined
|
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntitiesLocally(rawText)
|
const text = decodeHtmlEntities(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
const cacheId = resolvePartCacheId(part, text)
|
if (!partId) {
|
||||||
|
throw new Error("Markdown rendering requires a part id")
|
||||||
|
}
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
const { partId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
createEffect(async () => {
|
||||||
const cacheEntry: RenderCache = {
|
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||||
text: snapshot.text,
|
|
||||||
html: renderedHtml,
|
|
||||||
theme: snapshot.themeKey,
|
|
||||||
mode: snapshot.version,
|
|
||||||
}
|
|
||||||
setHtml(renderedHtml)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||||
const markdown = await loadMarkdownModule()
|
setMarkdownTheme(themeKey === "dark")
|
||||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
|
||||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
|
||||||
suppressHighlight: !snapshot.highlightEnabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
latestRequestedText = text
|
||||||
commitCacheEntry(snapshot, rendered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const snapshot = resolved()
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
return cache.theme === themeKey && cache.mode === version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = snapshot.part.renderCache
|
const localCache = part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -164,91 +92,115 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
setHtml(renderFallbackHtml(snapshot.text))
|
const commitCacheEntry = (renderedHtml: string) => {
|
||||||
notifyRendered()
|
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
if (!highlightEnabled) {
|
||||||
log.error("Failed to render markdown:", error)
|
part.renderCache = undefined
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
|
||||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
try {
|
||||||
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
|
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (event: Event) => {
|
const handleClick = async (e: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (!copyButton) {
|
if (copyButton) {
|
||||||
return
|
e.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (code) {
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (copyText) {
|
||||||
|
if (success) {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (!code) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (!copyText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
let disposed = false
|
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||||
void loadMarkdownModule()
|
if (props.disableHighlight) {
|
||||||
.then((markdown) => {
|
return
|
||||||
if (disposed) {
|
}
|
||||||
return
|
|
||||||
|
const { part, text, themeKey, version } = resolved()
|
||||||
|
|
||||||
|
setMarkdownTheme(themeKey === "dark")
|
||||||
|
|
||||||
|
if (latestRequestedText !== text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||||
|
setHtml(rendered)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
const snapshot = resolved()
|
}
|
||||||
if (!snapshot.highlightEnabled) {
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
log.error("Failed to load markdown module:", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
disposed = true
|
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener?.()
|
cleanupLanguageListener()
|
||||||
cleanupLanguageListener = undefined
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const proseClass = () => "markdown-body"
|
||||||
<div
|
|
||||||
ref={containerRef}
|
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
||||||
class="markdown-body"
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -578,29 +482,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
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))
|
||||||
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()
|
||||||
@@ -668,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
|
||||||
@@ -789,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
|
||||||
@@ -806,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}
|
||||||
/>
|
/>
|
||||||
@@ -827,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>
|
||||||
@@ -846,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"}>
|
||||||
@@ -863,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"}>
|
||||||
@@ -881,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"}>
|
||||||
@@ -895,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>
|
||||||
@@ -920,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 {
|
||||||
@@ -937,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)
|
||||||
@@ -956,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>
|
||||||
@@ -1057,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)
|
||||||
@@ -1104,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 = [
|
||||||
@@ -1170,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>
|
||||||
@@ -1273,21 +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))
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
@@ -1314,8 +973,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
return modelID
|
return modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
|
||||||
|
|
||||||
const reasoningText = () => {
|
const reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1356,44 +1013,29 @@ 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">
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1402,28 +1044,22 @@ 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">
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
<Show when={props.showDeleteMessage}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<input
|
<span class="message-step-meta-inline">
|
||||||
class="message-select-checkbox"
|
<Show when={agentIdentifier()}>
|
||||||
type="checkbox"
|
{(value) => (
|
||||||
checked={isSelectedForDeletion()}
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||||
onClick={(event) => {
|
)}
|
||||||
event.stopPropagation()
|
</Show>
|
||||||
}}
|
<Show when={modelIdentifier()}>
|
||||||
onChange={(event) => {
|
{(value) => (
|
||||||
event.stopPropagation()
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||||
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
)}
|
||||||
props.onToggleSelectedMessage?.(props.messageId, next)
|
</Show>
|
||||||
}}
|
</span>
|
||||||
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
</Show>
|
||||||
title={t("messageItem.selection.checkboxAriaLabel")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1444,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>
|
||||||
|
|
||||||
@@ -1476,23 +1097,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasMeta()}>
|
|
||||||
<div class="message-reasoning-meta-row">
|
|
||||||
<span class="message-step-meta-inline">
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
import { Portal } from "solid-js/web"
|
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
||||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessagePart } from "../stores/session-actions"
|
||||||
import { isTauriHost } from "../lib/runtime-env"
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
|
||||||
return (
|
|
||||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
|
||||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -28,112 +18,15 @@ interface MessageItemProps {
|
|||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
parts: ClientPart[]
|
parts: ClientPart[]
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
selectedMessageIds?: () => Set<string>
|
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
|
||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
showDeleteMessage?: boolean
|
|
||||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
|
||||||
|
|
||||||
type ImagePreviewState = {
|
|
||||||
url: string
|
|
||||||
name: string
|
|
||||||
anchor: HTMLElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
|
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
|
||||||
|
|
||||||
const getImagePreviewPosition = () => {
|
|
||||||
const state = imagePreview()
|
|
||||||
if (!state) return null
|
|
||||||
|
|
||||||
const rect = state.anchor.getBoundingClientRect()
|
|
||||||
|
|
||||||
// Outer box: 320px image + 8px padding on each side.
|
|
||||||
const padding = 8
|
|
||||||
const maxImage = 320
|
|
||||||
const gap = 8
|
|
||||||
const chrome = padding * 2
|
|
||||||
const outerWidth = maxImage + chrome
|
|
||||||
const outerHeight = maxImage + chrome
|
|
||||||
|
|
||||||
const viewportW = window.innerWidth
|
|
||||||
const viewportH = window.innerHeight
|
|
||||||
|
|
||||||
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
|
|
||||||
|
|
||||||
const fitsAbove = rect.top >= outerHeight + gap + 8
|
|
||||||
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
|
|
||||||
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
|
|
||||||
|
|
||||||
return { left, top }
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const active = imagePreview()
|
|
||||||
if (!active) return
|
|
||||||
|
|
||||||
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
|
|
||||||
// Hide the popover to avoid showing it in the wrong place.
|
|
||||||
const hide = () => setImagePreview(null)
|
|
||||||
window.addEventListener("scroll", hide, true)
|
|
||||||
window.addEventListener("resize", hide)
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("scroll", hide, true)
|
|
||||||
window.removeEventListener("resize", hide)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
|
||||||
|
|
||||||
let topRowEl: HTMLDivElement | undefined
|
|
||||||
let actionsEl: HTMLDivElement | undefined
|
|
||||||
let speakerPrimaryEl: HTMLDivElement | undefined
|
|
||||||
let metaMeasureEl: HTMLSpanElement | undefined
|
|
||||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
|
||||||
|
|
||||||
const metaText = () => agentMeta()
|
|
||||||
|
|
||||||
const updateMetaLayout = () => {
|
|
||||||
const text = metaText()
|
|
||||||
if (!text) return
|
|
||||||
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
|
|
||||||
|
|
||||||
const rowWidth = topRowEl.getBoundingClientRect().width
|
|
||||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
|
||||||
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
|
|
||||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
|
||||||
|
|
||||||
// Allow for the flex gap between left and actions.
|
|
||||||
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
|
|
||||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const text = metaText()
|
|
||||||
if (!text || typeof ResizeObserver === "undefined") {
|
|
||||||
setShowMetaInline(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetaLayout()
|
|
||||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
|
||||||
if (topRowEl) observer.observe(topRowEl)
|
|
||||||
if (actionsEl) observer.observe(actionsEl)
|
|
||||||
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
@@ -230,11 +123,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
|
|
||||||
if (!url) return
|
|
||||||
setImagePreview({ anchor, url, name })
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.error) return null
|
if (!info || info.role !== "assistant" || !info.error) return null
|
||||||
@@ -274,8 +162,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 +189,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 +257,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 +277,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 +309,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 +330,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 +349,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 +377,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>
|
<For each={messageParts()}>
|
||||||
{(part) => {
|
{(part) => (
|
||||||
return (
|
<MessagePart
|
||||||
<div class="message-part-shell">
|
part={part}
|
||||||
<MessagePart
|
messageType={props.record.role}
|
||||||
part={part}
|
instanceId={props.instanceId}
|
||||||
messageType={props.record.role}
|
sessionId={props.sessionId}
|
||||||
instanceId={props.instanceId}
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
sessionId={props.sessionId}
|
onRendered={props.onContentRendered}
|
||||||
primaryUserTextPartId={primaryUserTextPartId()}
|
/>
|
||||||
onRendered={props.onContentRendered}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
@@ -583,16 +396,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const name = getAttachmentName(attachment)
|
const name = getAttachmentName(attachment)
|
||||||
const isImage = isImageAttachment(attachment)
|
const isImage = isImageAttachment(attachment)
|
||||||
return (
|
return (
|
||||||
<div
|
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
|
||||||
title={name}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isImage) return
|
|
||||||
const el = e.currentTarget as HTMLElement
|
|
||||||
showImagePreview(el, attachment.url || "", name)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setImagePreview(null)}
|
|
||||||
>
|
|
||||||
<Show when={isImage} fallback={
|
<Show when={isImage} fallback={
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
@@ -620,6 +424,24 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeletePart(attachment.id)}
|
||||||
|
class="attachment-remove"
|
||||||
|
disabled={isDeletingPart(attachment.id)}
|
||||||
|
aria-label={t("messagePart.actions.deleteTitle")}
|
||||||
|
title={t("messagePart.actions.deleteTitle")}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Show when={isImage}>
|
||||||
|
<div class="attachment-chip-preview">
|
||||||
|
<img src={attachment.url} alt={name} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -627,31 +449,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" }>
|
||||||
@@ -16,18 +17,16 @@ interface MessagePartProps {
|
|||||||
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||||
primaryUserTextPartId?: string | null
|
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
|
||||||
@@ -58,11 +57,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 +91,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 +117,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,12 +171,17 @@ 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}`,
|
||||||
@@ -205,8 +190,7 @@ 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
|
||||||
@@ -215,14 +199,7 @@ export function buildTimelineSegments(
|
|||||||
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,10 +228,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -269,7 +235,6 @@ export function buildTimelineSegments(
|
|||||||
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
|
||||||
@@ -294,10 +257,6 @@ export function buildTimelineSegments(
|
|||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,13 +278,6 @@ 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) {
|
||||||
@@ -362,9 +314,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -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,6 +383,7 @@ 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)
|
||||||
@@ -663,255 +391,81 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import UnifiedPicker from "./unified-picker"
|
|||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||||
@@ -14,41 +13,12 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences } from "../stores/preferences"
|
import { preferences } from "../stores/preferences"
|
||||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||||
import type { Attachment } from "../types/attachment"
|
|
||||||
import { usePromptState } from "./prompt-input/usePromptState"
|
import { usePromptState } from "./prompt-input/usePromptState"
|
||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
|
||||||
if (!text || attachments.length === 0) return []
|
|
||||||
|
|
||||||
const usedCounters = new Set<string>()
|
|
||||||
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
|
||||||
const counter = match?.[1]
|
|
||||||
if (counter) usedCounters.add(counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usedCounters.size === 0) return []
|
|
||||||
|
|
||||||
const consumed = new Set<string>()
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
if (!attachment?.id) continue
|
|
||||||
if (attachment?.source?.type !== "text") continue
|
|
||||||
const display = attachment.display
|
|
||||||
if (typeof display !== "string") continue
|
|
||||||
const match = display.match(pastedDisplayCounterRegex)
|
|
||||||
if (!match?.[1]) continue
|
|
||||||
if (usedCounters.has(match[1])) {
|
|
||||||
consumed.add(attachment.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(consumed)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
@@ -206,26 +176,15 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -233,25 +192,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)
|
||||||
})
|
})
|
||||||
@@ -276,12 +226,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
commandName.length > 0 &&
|
commandName.length > 0 &&
|
||||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||||
|
|
||||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||||
const resolvedPrompt = isKnownSlashCommand
|
|
||||||
? resolvedCommandArgs
|
|
||||||
? `${commandToken} ${resolvedCommandArgs}`
|
|
||||||
: commandToken
|
|
||||||
: resolvePastedPlaceholders(text, currentAttachments)
|
|
||||||
const historyEntry = resolvedPrompt
|
const historyEntry = resolvedPrompt
|
||||||
|
|
||||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||||
@@ -297,10 +242,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
|
||||||
for (const attachmentId of consumedIds) {
|
|
||||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
|
||||||
}
|
|
||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
@@ -320,7 +261,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
} else if (isKnownSlashCommand) {
|
} else if (isKnownSlashCommand) {
|
||||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
@@ -390,9 +331,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) {
|
||||||
@@ -496,7 +435,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"
|
||||||
@@ -541,7 +480,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={
|
||||||
|
|||||||
@@ -17,12 +17,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,8 +1,8 @@
|
|||||||
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, removeAttachment } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
import type { PickerSelectAction } from "../unified-picker"
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
@@ -204,30 +204,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const folderMention =
|
const folderMention =
|
||||||
relativePath === "." || relativePath === "" || relativePath === "./"
|
relativePath === "." || relativePath === ""
|
||||||
? "./"
|
? "/"
|
||||||
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
: relativePath.replace(/\/+$/, "") + "/"
|
||||||
|
|
||||||
const normalizedFolderPath = (() => {
|
const normalizedFolderPath = (() => {
|
||||||
const trimmed = relativePath.replace(/\/+$/, "")
|
const trimmed = relativePath.replace(/\/+$/, "")
|
||||||
// If it's root "./", just return "./"
|
return trimmed.length > 0 ? trimmed : "."
|
||||||
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 (isFolder) {
|
||||||
if (action === "tab") {
|
if (action === "tab") {
|
||||||
// TAB on directory: autocomplete directory name and show its contents.
|
// TAB on directory: autocomplete directory name and show its contents.
|
||||||
@@ -239,14 +224,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const mentionText = `@${folderMention}`
|
const mentionText = `@${folderMention}`
|
||||||
|
|
||||||
if (action === "shiftEnter") {
|
if (action === "shiftEnter") {
|
||||||
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||||
// Always prefix with ./ for consistency
|
|
||||||
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
|
||||||
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
|
||||||
replaceMentionToken(mentionText, { trailingSpace: true })
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
} else {
|
} else {
|
||||||
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
||||||
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
const dirLabel =
|
||||||
|
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||||
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
@@ -255,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
if (!alreadyAttached) {
|
||||||
|
// Remove any parent/child directory attachments that overlap with this one
|
||||||
|
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
|
||||||
|
for (const att of existingAttachments) {
|
||||||
|
if (
|
||||||
|
att.source.type === "file" &&
|
||||||
|
att.source.mime === "inode/directory" &&
|
||||||
|
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
|
||||||
|
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
|
||||||
|
) {
|
||||||
|
// Remove the overlapping directory attachment
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), att.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
normalizedFolderPath,
|
normalizedFolderPath,
|
||||||
dirFilename,
|
dirFilename,
|
||||||
@@ -278,15 +275,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "shiftEnter") {
|
if (action === "shiftEnter") {
|
||||||
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||||
// Always prefix with ./ for consistency
|
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
|
||||||
addPathOnlyAttachment(normalizedPathWithPrefix)
|
|
||||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
|
||||||
} else {
|
} else {
|
||||||
// ENTER/click on file: attach file (existing behavior).
|
// ENTER/click on file: attach file (existing behavior).
|
||||||
// Always prefix with ./ for consistency
|
|
||||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
|
||||||
const pathSegments = normalizedPath.split("/")
|
const pathSegments = normalizedPath.split("/")
|
||||||
const filename = (() => {
|
const filename = (() => {
|
||||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
@@ -295,12 +287,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
if (!alreadyAttached) {
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
normalizedPathWithPrefix,
|
normalizedPath,
|
||||||
filename,
|
filename,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -309,7 +301,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,9 +334,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
nextTextarea.setSelectionRange(pos, pos)
|
nextTextarea.setSelectionRange(pos, pos)
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -363,19 +314,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
</Show>
|
</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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
|
||||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
|
||||||
import { createMemo, For, type Component } from "solid-js"
|
|
||||||
import { useI18n } from "../lib/i18n"
|
|
||||||
import {
|
|
||||||
activeSettingsSection,
|
|
||||||
closeSettings,
|
|
||||||
settingsOpen,
|
|
||||||
setActiveSettingsSection,
|
|
||||||
type SettingsSectionId,
|
|
||||||
} from "../stores/settings-screen"
|
|
||||||
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
|
|
||||||
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
|
||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const sections = createMemo(() => [
|
|
||||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
|
||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const renderSection = () => {
|
|
||||||
switch (activeSettingsSection()) {
|
|
||||||
case "notifications":
|
|
||||||
return <NotificationsSettingsSection />
|
|
||||||
case "remote":
|
|
||||||
return <RemoteAccessSettingsSection />
|
|
||||||
case "opencode":
|
|
||||||
return <OpenCodeSettingsSection />
|
|
||||||
case "appearance":
|
|
||||||
default:
|
|
||||||
return <AppearanceSettingsSection />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
|
||||||
<div class="settings-screen-frame">
|
|
||||||
<Dialog.Content class="modal-surface settings-screen-shell">
|
|
||||||
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
|
|
||||||
|
|
||||||
<aside class="settings-screen-nav">
|
|
||||||
<div class="settings-screen-nav-header">
|
|
||||||
<div class="settings-screen-nav-title-row">
|
|
||||||
<span class="settings-screen-nav-icon-wrap">
|
|
||||||
<Settings class="settings-screen-nav-icon" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 class="settings-screen-title">{t("settings.title")}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
|
|
||||||
<For each={sections()}>
|
|
||||||
{(section) => {
|
|
||||||
const Icon = section.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="settings-nav-button"
|
|
||||||
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
|
|
||||||
onClick={() => setActiveSettingsSection(section.id)}
|
|
||||||
>
|
|
||||||
<Icon class="settings-nav-button-icon" />
|
|
||||||
<span>{section.label}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="settings-screen-content">
|
|
||||||
<header class="settings-screen-content-header">
|
|
||||||
<div class="settings-screen-content-header-title-group">
|
|
||||||
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
|
|
||||||
<h1 class="settings-screen-content-title">
|
|
||||||
{sections().find((section) => section.id === activeSettingsSection())?.label}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary settings-screen-close"
|
|
||||||
onClick={closeSettings}
|
|
||||||
aria-label={t("settings.close")}
|
|
||||||
title={t("settings.close")}
|
|
||||||
>
|
|
||||||
<X class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="settings-screen-scroll">{renderSection()}</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</div>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import { Select } from "@kobalte/core/select"
|
|
||||||
import { createEffect, createMemo, createSignal, For, type Component } from "solid-js"
|
|
||||||
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
|
|
||||||
import { useI18n } from "../../lib/i18n"
|
|
||||||
import { useTheme, type ThemeMode } from "../../lib/theme"
|
|
||||||
import { useConfig } from "../../stores/preferences"
|
|
||||||
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
|
|
||||||
|
|
||||||
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
|
|
||||||
{ value: "system", icon: Laptop },
|
|
||||||
{ value: "light", icon: Sun },
|
|
||||||
{ value: "dark", icon: Moon },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const AppearanceSettingsSection: Component = () => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { themeMode, setThemeMode } = useTheme()
|
|
||||||
const {
|
|
||||||
preferences,
|
|
||||||
updatePreferences,
|
|
||||||
toggleShowThinkingBlocks,
|
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
|
||||||
toggleUsageMetrics,
|
|
||||||
toggleAutoCleanupBlankSessions,
|
|
||||||
togglePromptSubmitOnEnter,
|
|
||||||
setDiffViewMode,
|
|
||||||
setToolOutputExpansion,
|
|
||||||
setDiagnosticsExpansion,
|
|
||||||
setThinkingBlocksExpansion,
|
|
||||||
setToolInputsVisibility,
|
|
||||||
} = useConfig()
|
|
||||||
|
|
||||||
const behaviorSettings = createMemo(() =>
|
|
||||||
getBehaviorSettings({
|
|
||||||
preferences,
|
|
||||||
updatePreferences,
|
|
||||||
toggleShowThinkingBlocks,
|
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
|
||||||
toggleUsageMetrics,
|
|
||||||
toggleAutoCleanupBlankSessions,
|
|
||||||
togglePromptSubmitOnEnter,
|
|
||||||
setDiffViewMode,
|
|
||||||
setToolOutputExpansion,
|
|
||||||
setDiagnosticsExpansion,
|
|
||||||
setThinkingBlocksExpansion,
|
|
||||||
setToolInputsVisibility,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
|
|
||||||
|
|
||||||
const setOverride = (id: string, value: unknown) => {
|
|
||||||
setOverrides((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
next.set(id, value)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = overrides()
|
|
||||||
if (current.size === 0) return
|
|
||||||
|
|
||||||
const prefs = preferences()
|
|
||||||
const settings = behaviorSettings()
|
|
||||||
|
|
||||||
let changed = false
|
|
||||||
const next = new Map(current)
|
|
||||||
for (const setting of settings) {
|
|
||||||
if (!next.has(setting.id)) continue
|
|
||||||
const overrideValue = next.get(setting.id)
|
|
||||||
const actualValue = setting.get(prefs)
|
|
||||||
if (Object.is(actualValue, overrideValue)) {
|
|
||||||
next.delete(setting.id)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
setOverrides(next)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const readSettingValue = (setting: BehaviorSetting) => {
|
|
||||||
const current = overrides()
|
|
||||||
if (current.has(setting.id)) return current.get(setting.id)
|
|
||||||
return setting.get(preferences())
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectOption = { value: string; label: string }
|
|
||||||
|
|
||||||
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
|
|
||||||
const setting = props.setting
|
|
||||||
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
|
|
||||||
|
|
||||||
if (setting.kind === "toggle") {
|
|
||||||
const options = createMemo<SelectOption[]>(() => [
|
|
||||||
{ value: "true", label: t("settings.common.enabled") },
|
|
||||||
{ value: "false", label: t("settings.common.disabled") },
|
|
||||||
])
|
|
||||||
const currentValue = createMemo(() => String(Boolean(readSettingValue(setting))))
|
|
||||||
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
|
||||||
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
|
||||||
</div>
|
|
||||||
<Select<SelectOption>
|
|
||||||
value={selectedOption()}
|
|
||||||
onChange={(opt) => {
|
|
||||||
if (!opt) return
|
|
||||||
const next = opt.value === "true"
|
|
||||||
setOverride(setting.id, next)
|
|
||||||
setting.set(next)
|
|
||||||
}}
|
|
||||||
options={options()}
|
|
||||||
optionValue="value"
|
|
||||||
optionTextValue="label"
|
|
||||||
disabled={disabled()}
|
|
||||||
itemComponent={(itemProps) => (
|
|
||||||
<Select.Item item={itemProps.item} class="selector-option">
|
|
||||||
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
|
||||||
</Select.Item>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<Select.Value<SelectOption>>
|
|
||||||
{(state) => (
|
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
|
||||||
{state.selectedOption()?.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Select.Value>
|
|
||||||
</div>
|
|
||||||
<Select.Icon class="selector-trigger-icon">
|
|
||||||
<ChevronDown class="w-3 h-3" />
|
|
||||||
</Select.Icon>
|
|
||||||
</Select.Trigger>
|
|
||||||
|
|
||||||
<Select.Portal>
|
|
||||||
<Select.Content class="selector-popover">
|
|
||||||
<Select.Listbox class="selector-listbox" />
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Portal>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const enumSetting = setting as Extract<BehaviorSetting, { kind: "enum" }>
|
|
||||||
const options = createMemo<SelectOption[]>(() =>
|
|
||||||
enumSetting.options.map((opt: { value: string; labelKey: string }) => ({
|
|
||||||
value: String(opt.value),
|
|
||||||
label: t(opt.labelKey),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const currentValue = createMemo(() => String(readSettingValue(setting) ?? ""))
|
|
||||||
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
|
||||||
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
|
||||||
</div>
|
|
||||||
<Select<SelectOption>
|
|
||||||
value={selectedOption()}
|
|
||||||
onChange={(opt) => {
|
|
||||||
if (!opt) return
|
|
||||||
setOverride(setting.id, opt.value)
|
|
||||||
enumSetting.set(opt.value as any)
|
|
||||||
}}
|
|
||||||
options={options()}
|
|
||||||
optionValue="value"
|
|
||||||
optionTextValue="label"
|
|
||||||
disabled={disabled()}
|
|
||||||
itemComponent={(itemProps) => (
|
|
||||||
<Select.Item item={itemProps.item} class="selector-option">
|
|
||||||
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
|
||||||
</Select.Item>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<Select.Value<SelectOption>>
|
|
||||||
{(state) => (
|
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
|
||||||
{state.selectedOption()?.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Select.Value>
|
|
||||||
</div>
|
|
||||||
<Select.Icon class="selector-trigger-icon">
|
|
||||||
<ChevronDown class="w-3 h-3" />
|
|
||||||
</Select.Icon>
|
|
||||||
</Select.Trigger>
|
|
||||||
|
|
||||||
<Select.Portal>
|
|
||||||
<Select.Content class="selector-popover">
|
|
||||||
<Select.Listbox class="selector-listbox" />
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Portal>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeLabel = (mode: ThemeMode) => {
|
|
||||||
if (mode === "system") return t("theme.mode.system")
|
|
||||||
if (mode === "light") return t("theme.mode.light")
|
|
||||||
return t("theme.mode.dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="settings-section-stack">
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-choice-grid">
|
|
||||||
{themeModeOptions.map((option) => {
|
|
||||||
const Icon = option.icon
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="settings-choice"
|
|
||||||
data-selected={themeMode() === option.value ? "true" : "false"}
|
|
||||||
onClick={() => setThemeMode(option.value)}
|
|
||||||
>
|
|
||||||
<span class="settings-choice-icon-wrap">
|
|
||||||
<Icon class="settings-choice-icon" />
|
|
||||||
</span>
|
|
||||||
<span class="settings-choice-copy">
|
|
||||||
<span class="settings-choice-label">{modeLabel(option.value)}</span>
|
|
||||||
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
|
|
||||||
</span>
|
|
||||||
<span class="settings-choice-check" aria-hidden="true">
|
|
||||||
<Check class="w-4 h-4" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-stack">
|
|
||||||
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { Show, createEffect, createResource, type Component } from "solid-js"
|
|
||||||
import { Bell } from "lucide-solid"
|
|
||||||
import { showToastNotification } from "../../lib/notifications"
|
|
||||||
import {
|
|
||||||
getOsNotificationCapability,
|
|
||||||
requestOsNotificationPermission,
|
|
||||||
type OsNotificationPermission,
|
|
||||||
} from "../../lib/os-notifications"
|
|
||||||
import { useConfig } from "../../stores/preferences"
|
|
||||||
import { useI18n } from "../../lib/i18n"
|
|
||||||
|
|
||||||
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
|
|
||||||
switch (permission) {
|
|
||||||
case "granted":
|
|
||||||
return t("settings.notifications.permission.granted")
|
|
||||||
case "denied":
|
|
||||||
return t("settings.notifications.permission.denied")
|
|
||||||
case "default":
|
|
||||||
return t("settings.notifications.permission.default")
|
|
||||||
case "unsupported":
|
|
||||||
return t("settings.notifications.permission.unsupported")
|
|
||||||
default:
|
|
||||||
return String(permission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotificationsSettingsSection: Component = () => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { preferences, updatePreferences } = useConfig()
|
|
||||||
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void refetch()
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleEnableToggle = async (enabled: boolean) => {
|
|
||||||
if (!enabled) {
|
|
||||||
updatePreferences({ osNotificationsEnabled: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cap = capability()
|
|
||||||
if (cap && !cap.supported) {
|
|
||||||
showToastNotification({
|
|
||||||
title: t("settings.section.notifications.title"),
|
|
||||||
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
updatePreferences({ osNotificationsEnabled: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await requestOsNotificationPermission()
|
|
||||||
if (permission !== "granted") {
|
|
||||||
showToastNotification({
|
|
||||||
title: t("settings.section.notifications.title"),
|
|
||||||
message:
|
|
||||||
permission === "denied"
|
|
||||||
? t("settings.notifications.messages.permissionDenied")
|
|
||||||
: t("settings.notifications.messages.permissionNotGranted"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
updatePreferences({ osNotificationsEnabled: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreferences({ osNotificationsEnabled: true })
|
|
||||||
void refetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRequestPermission = async () => {
|
|
||||||
const cap = capability()
|
|
||||||
if (cap && !cap.supported) {
|
|
||||||
showToastNotification({
|
|
||||||
title: t("settings.section.notifications.title"),
|
|
||||||
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await requestOsNotificationPermission()
|
|
||||||
if (permission === "granted") {
|
|
||||||
showToastNotification({
|
|
||||||
title: t("settings.section.notifications.title"),
|
|
||||||
message: t("settings.notifications.messages.permissionGranted"),
|
|
||||||
variant: "success",
|
|
||||||
duration: 6000,
|
|
||||||
})
|
|
||||||
void refetch()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showToastNotification({
|
|
||||||
title: t("settings.section.notifications.title"),
|
|
||||||
message:
|
|
||||||
permission === "denied"
|
|
||||||
? t("settings.notifications.messages.permissionRequestDenied")
|
|
||||||
: t("settings.notifications.messages.permissionNotGranted"),
|
|
||||||
variant: "warning",
|
|
||||||
})
|
|
||||||
void refetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const supported = () => capability()?.supported ?? false
|
|
||||||
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
|
|
||||||
const infoMessage = () => capability()?.info
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="settings-section-stack">
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div class="settings-card-heading-with-icon">
|
|
||||||
<Bell class="settings-card-heading-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-stack">
|
|
||||||
<div class="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
|
|
||||||
<div class="settings-toggle-caption">
|
|
||||||
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="settings-checkbox-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(preferences().osNotificationsEnabled)}
|
|
||||||
disabled={!supported() && capability.state === "ready"}
|
|
||||||
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
<span>{t("settings.common.enabled")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
|
||||||
<div class="settings-toggle-row settings-toggle-row-compact">
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
|
|
||||||
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
|
||||||
onClick={() => void handleRequestPermission()}
|
|
||||||
>
|
|
||||||
{t("settings.notifications.requestPermission.action")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
|
|
||||||
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
|
|
||||||
</div>
|
|
||||||
<label class="settings-checkbox-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
|
||||||
disabled={!preferences().osNotificationsEnabled}
|
|
||||||
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
<span>{t("settings.common.enabled")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={Boolean(infoMessage())}>
|
|
||||||
<div class="settings-inline-note">{infoMessage()}</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!supported() && capability.state === "ready"}>
|
|
||||||
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-stack">
|
|
||||||
<div class="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
|
|
||||||
</div>
|
|
||||||
<label class="settings-checkbox-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(preferences().notifyOnNeedsInput)}
|
|
||||||
disabled={!preferences().osNotificationsEnabled}
|
|
||||||
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
<span>{t("settings.common.enabled")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
|
|
||||||
</div>
|
|
||||||
<label class="settings-checkbox-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(preferences().notifyOnIdle)}
|
|
||||||
disabled={!preferences().osNotificationsEnabled}
|
|
||||||
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
|
|
||||||
/>
|
|
||||||
<span>{t("settings.common.enabled")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createEffect, createSignal, type Component } from "solid-js"
|
|
||||||
import { Terminal } from "lucide-solid"
|
|
||||||
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
|
||||||
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
|
||||||
import { useConfig } from "../../stores/preferences"
|
|
||||||
import { useI18n } from "../../lib/i18n"
|
|
||||||
|
|
||||||
export const OpenCodeSettingsSection: Component = () => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { serverSettings, updateLastUsedBinary } = useConfig()
|
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const binary = serverSettings().opencodeBinary || "opencode"
|
|
||||||
setSelectedBinary((current) => (current === binary ? current : binary))
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleBinaryChange = (binary: string) => {
|
|
||||||
setSelectedBinary(binary)
|
|
||||||
updateLastUsedBinary(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="settings-section-stack">
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div class="settings-card-heading-with-icon">
|
|
||||||
<Terminal class="settings-card-heading-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
|
||||||
</div>
|
|
||||||
<EnvironmentVariablesEditor />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
|
||||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
|
||||||
import { toDataURL } from "qrcode"
|
|
||||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
|
||||||
import { serverApi } from "../../lib/api-client"
|
|
||||||
import { restartCli } from "../../lib/native/cli"
|
|
||||||
import { serverSettings, setListeningMode } from "../../stores/preferences"
|
|
||||||
import { showConfirmDialog } from "../../stores/alerts"
|
|
||||||
import { getLogger } from "../../lib/logger"
|
|
||||||
import { useI18n } from "../../lib/i18n"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
|
||||||
|
|
||||||
export const RemoteAccessSettingsSection: Component = () => {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
|
||||||
const [authStatus, setAuthStatus] = createSignal<{
|
|
||||||
authenticated: boolean
|
|
||||||
username?: string
|
|
||||||
passwordUserProvided?: boolean
|
|
||||||
} | null>(null)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
|
||||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
|
||||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
|
||||||
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
|
||||||
const [passwordValue, setPasswordValue] = createSignal("")
|
|
||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
|
||||||
const displayAddresses = createMemo(() => {
|
|
||||||
const list = addresses()
|
|
||||||
if (!allowExternalConnections()) return []
|
|
||||||
return list.filter((address) => address.scope !== "loopback")
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setPasswordError(null)
|
|
||||||
try {
|
|
||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
|
||||||
setMeta(metaResult)
|
|
||||||
setAuthStatus(authResult)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void refreshMeta()
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleExpanded = async (url: string) => {
|
|
||||||
if (expandedUrl() === url) {
|
|
||||||
setExpandedUrl(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExpandedUrl(url)
|
|
||||||
if (!qrCodes()[url]) {
|
|
||||||
try {
|
|
||||||
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
|
||||||
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
|
||||||
} catch (err) {
|
|
||||||
log.error("Failed to generate QR code", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAllowConnectionsChange = async (checked: boolean) => {
|
|
||||||
const targetMode: "local" | "all" = checked ? "all" : "local"
|
|
||||||
if (targetMode === currentMode() || applyingListeningMode()) return
|
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
|
||||||
title: checked
|
|
||||||
? t("remoteAccess.listeningMode.restartConfirm.title.all")
|
|
||||||
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
|
||||||
variant: "warning",
|
|
||||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
|
||||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
setApplyingListeningMode(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await setListeningMode(targetMode)
|
|
||||||
const restarted = await restartCli()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenUrl = (url: string) => {
|
|
||||||
try {
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer")
|
|
||||||
} catch (err) {
|
|
||||||
log.error("Failed to open URL", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitPassword = async () => {
|
|
||||||
setPasswordError(null)
|
|
||||||
|
|
||||||
const next = passwordValue()
|
|
||||||
const confirm = passwordConfirm()
|
|
||||||
if (next.trim().length < 8) {
|
|
||||||
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (next !== confirm) {
|
|
||||||
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingPassword(true)
|
|
||||||
try {
|
|
||||||
const result = await serverApi.setServerPassword(next)
|
|
||||||
setAuthStatus({
|
|
||||||
authenticated: true,
|
|
||||||
username: result.username,
|
|
||||||
passwordUserProvided: result.passwordUserProvided,
|
|
||||||
})
|
|
||||||
setPasswordValue("")
|
|
||||||
setPasswordConfirm("")
|
|
||||||
setPasswordFormOpen(false)
|
|
||||||
} catch (err) {
|
|
||||||
setPasswordError(err instanceof Error ? err.message : String(err))
|
|
||||||
} finally {
|
|
||||||
setSavingPassword(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="settings-section-stack">
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div class="settings-card-heading-with-icon">
|
|
||||||
<Shield class="settings-card-heading-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="settings-toolbar-inline">
|
|
||||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
|
||||||
<button
|
|
||||||
class="selector-button selector-button-secondary w-auto"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void refreshMeta()}
|
|
||||||
disabled={loading()}
|
|
||||||
>
|
|
||||||
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
|
|
||||||
<span>{t("remoteAccess.refresh")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
class="remote-toggle"
|
|
||||||
checked={allowExternalConnections()}
|
|
||||||
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
|
|
||||||
disabled={loading() || applyingListeningMode()}
|
|
||||||
>
|
|
||||||
<Switch.Input />
|
|
||||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
|
||||||
<span class="remote-toggle-state">
|
|
||||||
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
|
|
||||||
</span>
|
|
||||||
<Switch.Thumb class="remote-toggle-thumb" />
|
|
||||||
</Switch.Control>
|
|
||||||
<div class="remote-toggle-copy">
|
|
||||||
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
|
||||||
<span class="remote-toggle-caption">
|
|
||||||
{allowExternalConnections()
|
|
||||||
? t("remoteAccess.toggle.caption.all")
|
|
||||||
: t("remoteAccess.toggle.caption.local")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Switch>
|
|
||||||
|
|
||||||
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div class="settings-card-heading-with-icon">
|
|
||||||
<Shield class="settings-card-heading-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={authStatus() && authStatus()!.authenticated}
|
|
||||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
|
||||||
>
|
|
||||||
<div class="settings-card-content">
|
|
||||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
|
||||||
<p class="settings-help-text">
|
|
||||||
{authStatus()!.passwordUserProvided
|
|
||||||
? t("remoteAccess.password.status.set")
|
|
||||||
: t("remoteAccess.password.status.unset")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="settings-password-actions">
|
|
||||||
<button
|
|
||||||
class="settings-pill-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setPasswordFormOpen(!passwordFormOpen())
|
|
||||||
setPasswordError(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{passwordFormOpen()
|
|
||||||
? t("remoteAccess.password.actions.cancel")
|
|
||||||
: authStatus()!.passwordUserProvided
|
|
||||||
? t("remoteAccess.password.actions.change")
|
|
||||||
: t("remoteAccess.password.actions.set")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={passwordFormOpen()}>
|
|
||||||
<div class="settings-form-group">
|
|
||||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
|
||||||
<input
|
|
||||||
class="selector-input w-full"
|
|
||||||
type="password"
|
|
||||||
value={passwordValue()}
|
|
||||||
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
|
||||||
placeholder={t("remoteAccess.password.form.placeholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="settings-form-group">
|
|
||||||
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
|
|
||||||
<input
|
|
||||||
class="selector-input w-full"
|
|
||||||
type="password"
|
|
||||||
value={passwordConfirm()}
|
|
||||||
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={passwordError()}>
|
|
||||||
{(message) => <div class="settings-error-message">{message()}</div>}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="settings-password-actions">
|
|
||||||
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
|
|
||||||
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
|
||||||
<div class="settings-card-header">
|
|
||||||
<div class="settings-card-heading-with-icon">
|
|
||||||
<Wifi class="settings-card-heading-icon" />
|
|
||||||
<div>
|
|
||||||
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
|
|
||||||
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
|
||||||
<Show
|
|
||||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
|
||||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
|
||||||
>
|
|
||||||
<div class="remote-address-list">
|
|
||||||
<Show when={meta()?.localUrl}>
|
|
||||||
{(url) => {
|
|
||||||
const value = () => url()
|
|
||||||
const expandedState = () => expandedUrl() === value()
|
|
||||||
const qr = () => qrCodes()[value()]
|
|
||||||
return (
|
|
||||||
<div class="remote-address">
|
|
||||||
<div class="remote-address-main">
|
|
||||||
<div>
|
|
||||||
<p class="remote-address-url">{value()}</p>
|
|
||||||
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
|
||||||
</div>
|
|
||||||
<div class="remote-actions">
|
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
|
||||||
<ExternalLink class="remote-icon" />
|
|
||||||
{t("remoteAccess.address.open")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="remote-pill"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleExpanded(value())}
|
|
||||||
aria-expanded={expandedState()}
|
|
||||||
>
|
|
||||||
<Link2 class="remote-icon" />
|
|
||||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={expandedState()}>
|
|
||||||
<div class="remote-qr">
|
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
|
||||||
{(dataUrl) => (
|
|
||||||
<img
|
|
||||||
src={dataUrl()}
|
|
||||||
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
|
||||||
class="remote-qr-img"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<For each={displayAddresses()}>
|
|
||||||
{(address) => {
|
|
||||||
const url = address.remoteUrl
|
|
||||||
const expandedState = () => expandedUrl() === url
|
|
||||||
const qr = () => qrCodes()[url]
|
|
||||||
const scopeLabel = () =>
|
|
||||||
address.scope === "external"
|
|
||||||
? t("remoteAccess.address.scope.network")
|
|
||||||
: address.scope === "loopback"
|
|
||||||
? t("remoteAccess.address.scope.loopback")
|
|
||||||
: t("remoteAccess.address.scope.internal")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="remote-address">
|
|
||||||
<div class="remote-address-main">
|
|
||||||
<div>
|
|
||||||
<p class="remote-address-url">{url}</p>
|
|
||||||
<p class="remote-address-meta">
|
|
||||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="remote-actions">
|
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
|
||||||
<ExternalLink class="remote-icon" />
|
|
||||||
{t("remoteAccess.address.open")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="remote-pill"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleExpanded(url)}
|
|
||||||
aria-expanded={expandedState()}
|
|
||||||
>
|
|
||||||
<Link2 class="remote-icon" />
|
|
||||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={expandedState()}>
|
|
||||||
<div class="remote-qr">
|
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
|
||||||
{(dataUrl) => (
|
|
||||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user