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:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
description: "Version to apply to workspace packages (release builds)"
|
||||
required: false
|
||||
@@ -28,21 +23,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
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:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -65,8 +45,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -76,21 +54,7 @@ jobs:
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
shell: bash
|
||||
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
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
@@ -101,112 +65,6 @@ jobs:
|
||||
- name: Build macOS binaries (Electron)
|
||||
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
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -218,15 +76,6 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
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:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -236,8 +85,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -268,15 +115,6 @@ jobs:
|
||||
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:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -286,8 +124,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -319,15 +155,6 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
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:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -337,8 +164,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -381,7 +206,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -421,8 +237,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -465,7 +279,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -505,8 +310,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -552,7 +355,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
if: ${{ inputs.upload }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -565,15 +368,6 @@ jobs:
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -594,8 +388,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -651,7 +443,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -677,15 +469,6 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
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)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -707,8 +490,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -806,8 +587,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -844,12 +623,3 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
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
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Nightly build of dev (only if dev has new commits)
|
||||
- cron: "0 1 * * *"
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
@@ -16,63 +15,25 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||
version_suffix: ${{ steps.vars.outputs.version_suffix }}
|
||||
steps:
|
||||
- name: Decide whether to run
|
||||
id: gate
|
||||
- name: Compute version suffix
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api() {
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$1"
|
||||
}
|
||||
|
||||
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
||||
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
||||
echo "Failed to resolve dev head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA8="${GITHUB_SHA::8}"
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA8="${DEV_SHA::8}"
|
||||
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
||||
|
||||
SHOULD_RUN="false"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
SHOULD_RUN="true"
|
||||
else
|
||||
# Nightly: only run if dev has advanced since last successful dev-release build.
|
||||
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
||||
if [ -z "${LAST_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
||||
SHOULD_RUN="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
||||
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prerelease:
|
||||
needs: gate
|
||||
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||
needs: prepare
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||
dist_tag: latest
|
||||
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
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
@@ -50,8 +46,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
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
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -24,8 +18,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
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:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref (branch, tag, or SHA) to build from"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
@@ -51,8 +46,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -91,7 +84,6 @@ jobs:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
@@ -103,8 +95,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
@@ -113,7 +103,6 @@ jobs:
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
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
|
||||
```
|
||||
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [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.
|
||||
For dev version
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
- **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.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
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",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -2809,9 +2809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -3253,9 +3253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3305,32 +3305,6 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -10244,6 +10218,14 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -10984,36 +10966,6 @@
|
||||
"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": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
@@ -12033,7 +11985,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12043,7 +11995,6 @@
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
@@ -12070,7 +12021,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12111,7 +12062,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12119,18 +12070,16 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.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",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
@@ -12143,8 +12092,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -31,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.12.3",
|
||||
"minServerVersion": "0.10.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -66,24 +65,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
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 }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
||||
@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
@@ -431,9 +431,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
if (options.dev) {
|
||||
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()
|
||||
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
||||
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
||||
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
||||
}
|
||||
|
||||
return args
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
@@ -12,14 +12,6 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
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)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -15,10 +15,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||
"scripts": {
|
||||
"dev": "npm run dev:info",
|
||||
"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-vite dev",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
@@ -45,7 +42,6 @@
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.24"
|
||||
"@opencode-ai/plugin": "1.1.53"
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,18 @@
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
@@ -28,26 +25,18 @@
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
To list all CLI options:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
|
||||
On startup, CodeNomad prints two URLs:
|
||||
|
||||
- `Local Connection URL : ...` (used by desktop shells)
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
@@ -55,19 +44,7 @@ npm install -g @neuralnomads/codenomad
|
||||
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
|
||||
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| 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) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--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 |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--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-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||
| `--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 |
|
||||
| `--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
|
||||
|
||||
@@ -149,14 +105,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
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).
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **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",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -46,4 +46,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
OpenCodeBinary,
|
||||
Preferences,
|
||||
@@ -182,9 +183,9 @@ export interface BinaryRecord {
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export type SettingsOwner = string
|
||||
export type SettingsBucket = Record<string, unknown>
|
||||
export type SettingsDoc = Record<string, unknown>
|
||||
export type AppConfig = ConfigFile
|
||||
export type AppConfigResponse = AppConfig
|
||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||
|
||||
export interface BinaryListResponse {
|
||||
binaries: BinaryRecord[]
|
||||
@@ -213,8 +214,8 @@ export type WorkspaceEventType =
|
||||
| "workspace.error"
|
||||
| "workspace.stopped"
|
||||
| "workspace.log"
|
||||
| "storage.configChanged"
|
||||
| "storage.stateChanged"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
@@ -225,8 +226,8 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||
| { type: "workspace.stopped"; workspaceId: string }
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { 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.stopped", handler)
|
||||
this.on("workspace.log", handler)
|
||||
this.on("storage.configChanged", handler)
|
||||
this.on("storage.stateChanged", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.error", handler)
|
||||
this.off("workspace.stopped", handler)
|
||||
this.off("workspace.log", handler)
|
||||
this.off("storage.configChanged", handler)
|
||||
this.off("storage.stateChanged", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
|
||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
||||
import { createRequire } from "module"
|
||||
import { createHttpServer } from "./server/http-server"
|
||||
import { WorkspaceManager } from "./workspaces/manager"
|
||||
import { ConfigStore } from "./config/store"
|
||||
import { resolveConfigLocation } from "./config/location"
|
||||
import { SettingsService } from "./settings/service"
|
||||
import { BinaryResolver } from "./settings/binaries"
|
||||
import { BinaryRegistry } from "./config/binaries"
|
||||
import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
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("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.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("--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 settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||
const binaryResolver = new BinaryResolver(settings)
|
||||
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||
|
||||
// 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({
|
||||
rootDir: options.rootDir,
|
||||
settings,
|
||||
binaryResolver,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.localUrl,
|
||||
@@ -383,7 +392,8 @@ async function main() {
|
||||
defaultPort: options.httpPort,
|
||||
protocol: "http",
|
||||
workspaceManager,
|
||||
settings,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
@@ -403,7 +413,8 @@ async function main() {
|
||||
protocol: "https",
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
workspaceManager,
|
||||
settings,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
fileSystemBrowser,
|
||||
eventBus,
|
||||
serverMeta,
|
||||
|
||||
@@ -9,11 +9,12 @@ import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
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 { EventBus } from "../events/bus"
|
||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||
import { registerSettingsRoutes } from "./routes/settings"
|
||||
import { registerConfigRoutes } from "./routes/config"
|
||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
@@ -36,7 +37,8 @@ interface HttpServerDeps {
|
||||
protocol: "http" | "https"
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
workspaceManager: WorkspaceManager
|
||||
settings: SettingsService
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
fileSystemBrowser: FileSystemBrowser
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
@@ -242,7 +244,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
})
|
||||
|
||||
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 })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
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"
|
||||
|
||||
// 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: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
@@ -472,43 +459,19 @@ async function proxyWorkspaceRequest(args: {
|
||||
return
|
||||
}
|
||||
|
||||
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||
try {
|
||||
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||
reply.code(400).send({ error: message })
|
||||
const directory = await resolveWorktreeDirectory({
|
||||
workspaceId,
|
||||
workspacePath: workspace.path,
|
||||
worktreeSlug,
|
||||
logger,
|
||||
})
|
||||
|
||||
if (!directory) {
|
||||
reply.code(404).send({ error: "Worktree not found" })
|
||||
return
|
||||
}
|
||||
let directory: string | null = null
|
||||
let forwardedSuffix = extracted.forwardedSuffix
|
||||
|
||||
if (extracted.overrideDirectory) {
|
||||
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 normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
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) {
|
||||
if (!pathSuffix || pathSuffix === "/") {
|
||||
return "/"
|
||||
|
||||
@@ -119,8 +119,7 @@
|
||||
showError(message || `Login failed (${res.status})`)
|
||||
return
|
||||
}
|
||||
// Replace history entry so Back doesn't return to /login.
|
||||
window.location.replace("/")
|
||||
window.location.href = "/"
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : String(e))
|
||||
}
|
||||
|
||||
@@ -51,19 +51,7 @@ function getTokenHtml(): string {
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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")
|
||||
|
||||
app.get("/login", async (_request, reply) => {
|
||||
const status = deps.authManager.getStatus()
|
||||
reply.type("text/html").send(getLoginHtml(status.username))
|
||||
})
|
||||
@@ -79,11 +67,6 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
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())
|
||||
})
|
||||
|
||||
|
||||
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 { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import type { BinaryResolver } from "../settings/binaries"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
settings: SettingsService
|
||||
binaryResolver: BinaryResolver
|
||||
configStore: ConfigStore
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
@@ -86,7 +86,7 @@ export class WorkspaceManager {
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
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 workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
@@ -109,14 +109,17 @@ export class WorkspaceManager {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||
const envVars = (serverConfig as any)?.environmentVariables
|
||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
|
||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||
const opencodePassword = generateOpencodeServerPassword()
|
||||
@@ -145,10 +148,7 @@ export class WorkspaceManager {
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
if (runtimeVersion) {
|
||||
descriptor.binaryVersion = runtimeVersion
|
||||
}
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
@@ -277,12 +277,42 @@ export class WorkspaceManager {
|
||||
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: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}): Promise<string | undefined> {
|
||||
}) {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
@@ -296,7 +326,7 @@ export class WorkspaceManager {
|
||||
}),
|
||||
])
|
||||
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
@@ -309,8 +339,6 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -318,7 +346,7 @@ export class WorkspaceManager {
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}): Promise<string | undefined> {
|
||||
}) {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
@@ -332,7 +360,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return probeResult.version
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -343,11 +371,8 @@ export class WorkspaceManager {
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(
|
||||
workspaceId: string,
|
||||
port: number,
|
||||
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -358,22 +383,11 @@ export class WorkspaceManager {
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
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")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
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 }
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
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_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
if (process.platform !== "win32") {
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -20,7 +20,6 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"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 = {
|
||||
...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() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.12.3"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -19,12 +19,9 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"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",
|
||||
|
||||
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",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2378,6 +2378,36 @@
|
||||
"const": "dialog:deny-save",
|
||||
"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`",
|
||||
"type": "string",
|
||||
|
||||
@@ -9,8 +9,6 @@ use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -19,24 +17,10 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
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) {
|
||||
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> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
@@ -52,46 +36,6 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
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) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -196,16 +140,9 @@ struct PreferencesConfig {
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ServerConfig {
|
||||
#[serde(rename = "listeningMode")]
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
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(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||
let mode = config
|
||||
.server
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|srv| srv.listening_mode.as_ref())
|
||||
.or_else(|| {
|
||||
config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
});
|
||||
|
||||
if let Some(mode) = mode {
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
@@ -276,17 +206,11 @@ fn resolve_listening_mode() -> String {
|
||||
// Legacy fallback.
|
||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
let mode = config
|
||||
.server
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|srv| srv.listening_mode.as_ref())
|
||||
.or_else(|| {
|
||||
config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
});
|
||||
if let Some(mode) = mode {
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
@@ -404,19 +328,11 @@ impl CliProcessManager {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
// 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);
|
||||
}
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), false) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -432,17 +348,11 @@ impl CliProcessManager {
|
||||
));
|
||||
#[cfg(unix)]
|
||||
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);
|
||||
}
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -520,12 +430,9 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
@@ -535,12 +442,9 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
@@ -613,24 +517,7 @@ impl CliProcessManager {
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
#[cfg(unix)]
|
||||
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 _ = child.kill();
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
@@ -921,31 +808,14 @@ impl CliEntry {
|
||||
|
||||
if dev {
|
||||
// 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("false".to_string());
|
||||
args.push("--http".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);
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push(log_level);
|
||||
args.push("debug".to_string());
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
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 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");
|
||||
candidates.push(Some(resources.join("server/dist/bin.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 {
|
||||
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||
clean
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
} else {
|
||||
path
|
||||
};
|
||||
|
||||
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
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
@@ -15,31 +12,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
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);
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
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]
|
||||
@@ -58,38 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
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 {
|
||||
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`.
|
||||
// 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.
|
||||
"http" | "https" => matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||
),
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -124,55 +66,6 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
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() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -181,14 +74,13 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -200,12 +92,7 @@ fn main() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop
|
||||
])
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
@@ -300,27 +187,6 @@ fn main() {
|
||||
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 {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
@@ -368,16 +234,13 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N"),
|
||||
Some("CmdOrCtrl+N")
|
||||
)?;
|
||||
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(
|
||||
if is_mac { "close" } else { "quit" },
|
||||
if is_mac { "Close" } else { "Quit" },
|
||||
)
|
||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||
.build()?;
|
||||
submenus.push(file_menu);
|
||||
|
||||
@@ -400,6 +263,7 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.separator()
|
||||
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.build()?;
|
||||
@@ -413,12 +277,9 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||
.iter()
|
||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||
.collect();
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.12.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.10.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -13,15 +13,13 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@opencode-ai/sdk": "1.1.11",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.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-notification": "^2.3.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -32,8 +30,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
"tauri-plugin-keepawake-api": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
@@ -45,4 +42,4 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import { Minimize2 } from "lucide-solid"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
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 { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
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 { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
@@ -52,18 +50,16 @@ import {
|
||||
} from "./stores/sessions"
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
@@ -72,112 +68,26 @@ const App: Component = () => {
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
} = useConfig()
|
||||
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 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 = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof document === "undefined") return
|
||||
syncBrowserFullscreenState()
|
||||
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
|
||||
const updateKeyboardOffset = () => {
|
||||
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
||||
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
||||
}
|
||||
|
||||
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
||||
schedule()
|
||||
vv.addEventListener("resize", schedule)
|
||||
vv.addEventListener("scroll", schedule)
|
||||
window.addEventListener("orientationchange", schedule)
|
||||
|
||||
onCleanup(() => {
|
||||
vv.removeEventListener("resize", schedule)
|
||||
vv.removeEventListener("scroll", schedule)
|
||||
window.removeEventListener("orientationchange", schedule)
|
||||
document.documentElement.style.removeProperty("--keyboard-offset")
|
||||
})
|
||||
})
|
||||
|
||||
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||
let lastBrowserFullscreen = false
|
||||
createEffect(() => {
|
||||
const active = browserFullscreenActive()
|
||||
const mode = mobileFullscreenMode()
|
||||
if (mode && lastBrowserFullscreen && !active) {
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
lastBrowserFullscreen = active
|
||||
})
|
||||
|
||||
// If we leave phone layout (rotation / resize), restore chrome.
|
||||
createEffect(() => {
|
||||
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||
void exitMobileFullscreen()
|
||||
}
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -233,26 +143,60 @@ const App: Component = () => {
|
||||
|
||||
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) {
|
||||
if (!folderPath) {
|
||||
return
|
||||
}
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
@@ -265,7 +209,7 @@ const App: Component = () => {
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
openSettings("opencode")
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
@@ -349,7 +293,6 @@ const App: Component = () => {
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
@@ -357,7 +300,6 @@ const App: Component = () => {
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
@@ -453,61 +395,37 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
||||
<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>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
/>
|
||||
</Show>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: isVisible() ? "flex" : "none" }}
|
||||
data-instance-id={instance.id}
|
||||
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||
data-instance-visible={isVisible() ? "true" : "false"}
|
||||
>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
isActiveInstance={isActiveInstance()}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
@@ -523,25 +441,41 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={showFolderSelection()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<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
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<SettingsScreen />
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
const availableAgents = createMemo(() => {
|
||||
const allAgents = instanceAgents()
|
||||
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)
|
||||
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">
|
||||
<Select.Value<Agent>>
|
||||
{() => (
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -115,28 +115,28 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<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}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertDialog
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter } from "../lib/markdown"
|
||||
import { escapeHtml } from "../lib/text-render-utils"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
|
||||
@@ -112,10 +112,6 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
|
||||
const groupedCommandList = () => processedCommands().groups
|
||||
const orderedCommands = () => processedCommands().ordered
|
||||
|
||||
const isCommandDisabled = (command: Command) => {
|
||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
||||
}
|
||||
const selectedIndex = createMemo(() => {
|
||||
const ordered = orderedCommands()
|
||||
if (ordered.length === 0) return -1
|
||||
@@ -142,11 +138,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const currentId = selectedCommandId()
|
||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
||||
setSelectedCommandId(ordered[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -200,14 +195,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(command: Command) {
|
||||
if (isCommandDisabled(command)) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
@@ -272,13 +265,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
<For each={group.commands}>
|
||||
{(command, localIndex) => {
|
||||
const commandIndex = group.startIndex + localIndex()
|
||||
const disabled = isCommandDisabled(command)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
disabled={disabled}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
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 { 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 type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
@@ -135,4 +134,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
serverSettings,
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ interface MonacoDiffViewerProps {
|
||||
after: string
|
||||
viewMode?: "split" | "unified"
|
||||
contextMode?: "expanded" | "collapsed"
|
||||
wordWrap?: "on" | "off"
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
@@ -55,17 +54,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||
wordWrap: "off",
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
lineNumbersMinChars: 4,
|
||||
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)
|
||||
@@ -87,7 +81,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
@@ -96,20 +89,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
: { enabled: false },
|
||||
wordWrap,
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer keyboard-hints">
|
||||
<div class="panel-footer">
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
import { Select } from "@kobalte/core/select"
|
||||
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 AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
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 GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
onClose?: () => void
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
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 nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
@@ -54,7 +53,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = serverSettings().opencodeBinary
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
if (!lastUsed) return
|
||||
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 {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -235,6 +209,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -257,6 +236,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -332,10 +316,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div
|
||||
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)"
|
||||
onDragEnter={folderDrop.bind.onDragEnter}
|
||||
onDragOver={folderDrop.bind.onDragOver}
|
||||
onDragLeave={folderDrop.bind.onDragLeave}
|
||||
onDrop={folderDrop.bind.onDrop}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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}>
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onClose?.()}
|
||||
aria-label={t("app.launchError.close")}
|
||||
title={t("app.launchError.closeTitle")}
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -423,7 +384,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
@@ -454,7 +415,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href={DISCORD_URL}
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
@@ -585,16 +548,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
{/* Advanced settings section */}
|
||||
<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">
|
||||
<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>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
@@ -610,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
</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">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -628,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -644,17 +607,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title={t("folderSelection.dialog.title")}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface HintRowProps {
|
||||
|
||||
const HintRow: Component<HintRowProps> = (props) => {
|
||||
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}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 InstanceInfo from "./instance-info"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
return (
|
||||
<div class="log-container">
|
||||
<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">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
</div>
|
||||
|
||||
<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 { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
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 {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
showDisposeButton?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
@@ -23,8 +16,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const [isDisposing, setIsDisposing] = createSignal(false)
|
||||
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
@@ -34,46 +25,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
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 (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -205,19 +156,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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 type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
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 { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -16,11 +17,13 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
@@ -30,10 +33,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||
return notificationsEnabled()
|
||||
? t("settings.notifications.status.enabled")
|
||||
: t("settings.notifications.status.disabled")
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -71,35 +72,32 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<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>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => openSettings("notifications")}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
)}
|
||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||
</div>
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
||||
<div class="panel-footer hidden sm:block">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<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 SessionView from "../session/session-view"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
import ContextMeter from "../context-meter"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
@@ -42,7 +41,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
import {
|
||||
@@ -62,9 +61,6 @@ const log = getLogger("session")
|
||||
|
||||
interface InstanceShellProps {
|
||||
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
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
@@ -73,11 +69,6 @@ interface InstanceShellProps {
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
tabBarOffset: number
|
||||
|
||||
// In-memory only: mobile immersive/fullscreen mode.
|
||||
mobileFullscreenMode: boolean
|
||||
onEnterMobileFullscreen: () => void
|
||||
onExitMobileFullscreen: () => void
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
@@ -118,7 +109,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||
|
||||
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
||||
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
|
||||
|
||||
const layoutMode = createMemo<LayoutMode>(() => {
|
||||
if (desktopQuery()) return "desktop"
|
||||
@@ -127,9 +117,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
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 rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
|
||||
@@ -362,6 +349,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
measureDrawerHost,
|
||||
})
|
||||
|
||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
||||
|
||||
|
||||
const formattedAvailableTokens = () => {
|
||||
const avail = tokenStats().avail
|
||||
if (typeof avail === "number") {
|
||||
return formatTokenTotal(avail)
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
const renderLeftPanel = () => {
|
||||
if (leftPinned()) {
|
||||
@@ -597,14 +594,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{renderLeftPanel()}
|
||||
|
||||
<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">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!compactHeaderLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<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]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
@@ -630,17 +626,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
>
|
||||
{t("instanceShell.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint kbd-hint">
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<span
|
||||
@@ -651,18 +647,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</span>
|
||||
</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"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
@@ -675,19 +659,22 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<Show when={!showingInfoView()}>
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||
<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.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</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>
|
||||
}
|
||||
>
|
||||
@@ -706,13 +693,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={!showingInfoView()}>
|
||||
<ContextMeter
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
formatTokens={formatTokenTotal}
|
||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||
/>
|
||||
<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.usedLabel")}
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||
</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>
|
||||
|
||||
<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]">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
@@ -738,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</span>
|
||||
|
||||
@@ -777,10 +769,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
@@ -803,14 +794,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
data-instance-id={props.instance.id}
|
||||
data-session-active={isActive() ? "true" : "false"}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
@@ -819,8 +808,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isPhoneLayout={isPhoneLayout()}
|
||||
compactPromptLayout={compactPromptLayout()}
|
||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||
onSidebarToggle={() => setLeftOpen(true)}
|
||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||
@@ -846,10 +833,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="instance-shell2 flex flex-col flex-1 min-h-0"
|
||||
data-instance-id={props.instance.id}
|
||||
>
|
||||
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
||||
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
{sessionLayout}
|
||||
</Show>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
} 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 IconButton from "@suid/material/IconButton"
|
||||
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 { Session } from "../../../../types/session"
|
||||
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 FilesTab from "./tabs/FilesTab"
|
||||
@@ -32,7 +32,6 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_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_PHONE_KEY,
|
||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||
@@ -103,9 +102,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||
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 [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||
@@ -199,11 +195,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
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 min = 200
|
||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||
@@ -747,10 +738,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
@@ -776,10 +765,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
|
||||
@@ -1,61 +1,50 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
viewMode: DiffViewMode
|
||||
contextMode: DiffContextMode
|
||||
wordWrapMode: DiffWordWrapMode
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
}
|
||||
|
||||
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 (
|
||||
<div class="file-viewer-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => props.onViewModeChange(nextViewMode())}
|
||||
aria-label={viewModeTitle()}
|
||||
title={viewModeTitle()}
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "split"}
|
||||
onClick={() => props.onViewModeChange("split")}
|
||||
>
|
||||
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="file-viewer-toolbar-icon-button"
|
||||
onClick={() => props.onContextModeChange(nextContextMode())}
|
||||
aria-label={contextModeTitle()}
|
||||
title={contextModeTitle()}
|
||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
||||
aria-pressed={props.viewMode === "unified"}
|
||||
onClick={() => props.onViewModeChange("unified")}
|
||||
>
|
||||
{nextContextMode() === "collapsed" ? (
|
||||
<FoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
Unified
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
||||
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
||||
aria-label={wordWrapTitle()}
|
||||
title={wordWrapTitle()}
|
||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
||||
aria-pressed={props.contextMode === "collapsed"}
|
||||
onClick={() => props.onContextModeChange("collapsed")}
|
||||
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>
|
||||
</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 SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -20,10 +18,8 @@ interface ChangesTabProps {
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -34,18 +30,14 @@ interface ChangesTabProps {
|
||||
}
|
||||
|
||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
const sessionId = createMemo(() => props.activeSessionId())
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
|
||||
const sorted = createMemo<any[]>(() => {
|
||||
const list = diffs()
|
||||
if (!Array.isArray(list)) return []
|
||||
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||
})
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const diffs = hasSession ? props.activeSessionDiffs() : null
|
||||
|
||||
const totals = createMemo(() => {
|
||||
return sorted().reduce(
|
||||
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||
@@ -53,61 +45,49 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
})
|
||||
|
||||
const mostChanged = createMemo<any | null>(() => {
|
||||
const items = sorted()
|
||||
if (items.length === 0) return null
|
||||
return items.reduce((best, item) => {
|
||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||
const bestScore = bestAdd + bestDel
|
||||
const mostChanged = sorted.length
|
||||
? sorted.reduce((best, item) => {
|
||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||
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 del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||
const score = add + del
|
||||
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 score = add + del
|
||||
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return best
|
||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||
}, items[0])
|
||||
})
|
||||
if (score > bestScore) return item
|
||||
if (score < bestScore) return best
|
||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||
}, sorted[0])
|
||||
: null
|
||||
|
||||
const selectedFileData = createMemo<any | null>(() => {
|
||||
// Auto-select the most-changed file if none selected.
|
||||
const currentSelected = props.selectedFile()
|
||||
const items = sorted()
|
||||
if (currentSelected) {
|
||||
const match = items.find((f) => f.file === currentSelected)
|
||||
if (match) return match
|
||||
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
||||
|
||||
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
||||
|
||||
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 = () => (
|
||||
<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">
|
||||
<Show
|
||||
when={selected && hasSession() && sortedList.length > 0 ? selected : null}
|
||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||
@@ -115,23 +95,14 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -143,11 +114,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<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={() => {
|
||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||
}}
|
||||
@@ -168,11 +139,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<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={() => {
|
||||
props.onSelectFile(item.file, true)
|
||||
}}
|
||||
@@ -193,6 +164,8 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
@@ -203,23 +176,12 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
<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 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>
|
||||
</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 }}
|
||||
@@ -230,7 +192,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
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 { RefreshCw } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||
return "Select a file to preview"
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<Suspense
|
||||
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>
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
|
||||
<For each={sorted}>
|
||||
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>{props.t("instanceInfo.loading")}</span>
|
||||
<span>Loading…</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
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 { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -31,10 +29,8 @@ interface GitChangesTabProps {
|
||||
|
||||
diffViewMode: Accessor<DiffViewMode>
|
||||
diffContextMode: Accessor<DiffContextMode>
|
||||
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||
onViewModeChange: (mode: DiffViewMode) => void
|
||||
onContextModeChange: (mode: DiffContextMode) => void
|
||||
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||
|
||||
onOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
@@ -48,18 +44,17 @@ interface GitChangesTabProps {
|
||||
}
|
||||
|
||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const sessionId = createMemo(() => props.activeSessionId())
|
||||
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||
const renderContent = (): JSX.Element => {
|
||||
const sessionId = props.activeSessionId()
|
||||
|
||||
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||
const list = entries()
|
||||
if (!Array.isArray(list)) return []
|
||||
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
})
|
||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
||||
const entries = hasSession ? props.entries() : null
|
||||
|
||||
const totals = createMemo(() => {
|
||||
return sorted().reduce(
|
||||
const sorted = Array.isArray(entries)
|
||||
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||
: []
|
||||
|
||||
const totals = sorted.reduce(
|
||||
(acc, item) => {
|
||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||
@@ -67,36 +62,32 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
},
|
||||
{ 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 fallbackPath = props.mostChangedPath()
|
||||
const found =
|
||||
list.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
||||
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 selectedEntry =
|
||||
sorted.find((item) => item.path === selectedPath) ||
|
||||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
||||
|
||||
const renderViewer = () => (
|
||||
<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">
|
||||
<Show
|
||||
when={props.selectedLoading()}
|
||||
@@ -106,12 +97,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
selected &&
|
||||
selectedEntry &&
|
||||
props.selectedBefore() !== null &&
|
||||
props.selectedAfter() !== null &&
|
||||
selected.status !== "deleted"
|
||||
selectedEntry.status !== "deleted"
|
||||
? {
|
||||
path: selected.path,
|
||||
path: selectedEntry.path,
|
||||
before: props.selectedBefore() as string,
|
||||
after: props.selectedAfter() as string,
|
||||
}
|
||||
@@ -124,23 +115,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<MonacoDiffViewer
|
||||
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()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
@@ -154,7 +136,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -164,8 +146,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||
|
||||
const renderListPanel = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
@@ -179,7 +161,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<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 when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -196,8 +178,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
const renderListOverlay = () => (
|
||||
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sortedList}>
|
||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
||||
<For each={sorted}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||
@@ -210,7 +192,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<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 when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -227,19 +209,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</span>
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
<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 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>
|
||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
@@ -249,23 +231,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
class="files-header-icon-button"
|
||||
title={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" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||
</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 }}
|
||||
viewer={renderViewer()}
|
||||
listOpen={props.listOpen()}
|
||||
@@ -274,7 +247,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
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 type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
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 { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||
@@ -207,25 +206,21 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
|
||||
render: renderStatusSessionChanges,
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
|
||||
render: renderPlanSectionContent,
|
||||
},
|
||||
{
|
||||
id: "background-processes",
|
||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
|
||||
render: renderBackgroundProcesses,
|
||||
},
|
||||
{
|
||||
id: "mcp",
|
||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -238,7 +233,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
{
|
||||
id: "lsp",
|
||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -251,7 +245,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
{
|
||||
id: "plugins",
|
||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
initialInstance={props.instance}
|
||||
@@ -283,23 +276,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<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>
|
||||
<span>{props.t(section.labelKey)}</span>
|
||||
<ChevronDown
|
||||
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 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_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_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||
|
||||
export const clampWidth = (value: number) =>
|
||||
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 type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { Session } from "../../../types/session"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
|
||||
|
||||
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 {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
||||
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 {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
const { t } = useI18n()
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestKey = ""
|
||||
let cleanupLanguageListener: (() => void) | undefined
|
||||
let latestRequestedText = ""
|
||||
|
||||
const notifyRendered = () => {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const version = resolvePartVersion(part, text)
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
theme: snapshot.themeKey,
|
||||
mode: snapshot.version,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||
const markdown = await loadMarkdownModule()
|
||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
})
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const snapshot = resolved()
|
||||
latestRequestKey = snapshot.requestKey
|
||||
latestRequestedText = text
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
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)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
@@ -164,91 +92,115 @@ export function Markdown(props: MarkdownProps) {
|
||||
const globalCache = cacheHandle.get<RenderCache>()
|
||||
if (globalCache && cacheMatches(globalCache)) {
|
||||
setHtml(globalCache.html)
|
||||
part.renderCache = globalCache
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
setHtml(renderFallbackHtml(snapshot.text))
|
||||
notifyRendered()
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||
if (!highlightEnabled) {
|
||||
part.renderCache = undefined
|
||||
|
||||
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(() => {
|
||||
const handleClick = async (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const handleClick = async (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||
|
||||
if (!copyButton) {
|
||||
return
|
||||
if (copyButton) {
|
||||
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)
|
||||
|
||||
let disposed = false
|
||||
void loadMarkdownModule()
|
||||
.then((markdown) => {
|
||||
if (disposed) {
|
||||
return
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
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()
|
||||
}
|
||||
|
||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||
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)
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
containerRef?.removeEventListener("click", handleClick)
|
||||
cleanupLanguageListener?.()
|
||||
cleanupLanguageListener = undefined
|
||||
cleanupLanguageListener()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
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()}
|
||||
/>
|
||||
)
|
||||
const proseClass = () => "markdown-body"
|
||||
|
||||
return <div ref={containerRef} class={proseClass()} 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 { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
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 { setActiveInstanceId } from "../stores/instances"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
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 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 ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||
return Boolean(state && state.status === "running")
|
||||
@@ -203,23 +194,8 @@ interface MessageContentItemProps {
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => 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) {
|
||||
@@ -246,9 +222,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
const partId = ids[idx]
|
||||
const part = current.parts[partId]?.data
|
||||
if (!part) continue
|
||||
if (!isSupportedPartType(part)) continue
|
||||
|
||||
if (!isContentPartType((part as any).type)) break
|
||||
if (
|
||||
part.type === "tool" ||
|
||||
part.type === "reasoning" ||
|
||||
part.type === "compaction" ||
|
||||
part.type === "step-start" ||
|
||||
part.type === "step-finish"
|
||||
) {
|
||||
break
|
||||
}
|
||||
resolved.push(part)
|
||||
}
|
||||
|
||||
@@ -274,9 +256,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
const partId = ids[idx]
|
||||
const part = current.parts[partId]?.data
|
||||
if (!part) continue
|
||||
if (!isSupportedPartType(part)) continue
|
||||
|
||||
if (!isContentPartType((part as any).type)) continue
|
||||
if (
|
||||
part.type === "tool" ||
|
||||
part.type === "reasoning" ||
|
||||
part.type === "compaction" ||
|
||||
part.type === "step-start" ||
|
||||
part.type === "step-finish"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (partHasRenderableText(part)) {
|
||||
return false
|
||||
}
|
||||
@@ -296,12 +284,7 @@ function MessageContentItem(props: MessageContentItemProps) {
|
||||
sessionId={props.sessionId}
|
||||
isQueued={isQueued()}
|
||||
showAgentMeta={showAgentMeta()}
|
||||
showDeleteMessage={props.showDeleteMessage}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
@@ -317,41 +300,11 @@ interface ToolCallItemProps {
|
||||
messageId: string
|
||||
partId: string
|
||||
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) {
|
||||
const { t } = useI18n()
|
||||
const [deletingMessage, setDeletingMessage] = 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 [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
const record = createMemo(() => props.store().getMessage(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 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 state = toolState()
|
||||
if (!state) return ""
|
||||
@@ -391,72 +352,38 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
navigateToTaskSession(location)
|
||||
}
|
||||
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!props.showDeleteMessage) return
|
||||
if (deletingMessage()) return
|
||||
if (deleteDisabled()) return
|
||||
|
||||
setDeletingMessage(true)
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeletingMessage(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)
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={toolPart()}>
|
||||
{(resolvedToolPart) => (
|
||||
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
|
||||
<>
|
||||
<div class="tool-call-header-label">
|
||||
<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>{t("messageBlock.tool.header")}</span>
|
||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={taskSessionId()}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
@@ -470,33 +397,16 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="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
|
||||
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>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={deleteDisabled()}
|
||||
onClick={handleDeleteToolPart}
|
||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -510,7 +420,7 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
@@ -562,13 +472,7 @@ interface MessageBlockProps {
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => 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
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
@@ -578,29 +482,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
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 current = record()
|
||||
@@ -668,9 +549,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
}
|
||||
|
||||
orderedParts.forEach((part, partIndex) => {
|
||||
if (!isSupportedPartType(part)) {
|
||||
return
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
flushContent()
|
||||
const partId = part.id
|
||||
@@ -789,13 +667,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
return (
|
||||
<Show when={block()}>
|
||||
{(resolvedBlock) => (
|
||||
<div
|
||||
class="message-stream-block"
|
||||
data-message-id={resolvedBlock().record.id}
|
||||
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||
>
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
||||
<For each={resolvedBlock().items}>
|
||||
{(item, index) => (
|
||||
{(item) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<MessageContentItem
|
||||
@@ -806,12 +680,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
startPartId={(item as ContentDisplayItem).startPartId}
|
||||
messageIndex={props.messageIndex}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
@@ -827,13 +696,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
store={props.store}
|
||||
messageId={toolItem.messageId}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -846,14 +708,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
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 when={item.type === "step-finish"}>
|
||||
@@ -863,14 +717,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showUsage={props.showUsageMetrics()}
|
||||
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 when={item.type === "compaction"}>
|
||||
@@ -881,11 +727,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as CompactionDisplayItem).messageId}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
partId={(item as CompactionDisplayItem).partId}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
@@ -895,13 +737,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
messageId={(item as ReasoningDisplayItem).messageId}
|
||||
partId={(item as ReasoningDisplayItem).partId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
showDeleteMessage={index() === 0}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -920,14 +758,6 @@ interface StepCardProps {
|
||||
showAgentMeta?: boolean
|
||||
showUsage?: boolean
|
||||
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 {
|
||||
@@ -937,18 +767,12 @@ interface CompactionCardProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
partId: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: CompactionCardProps) {
|
||||
const { t } = useI18n()
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
@@ -956,98 +780,44 @@ function CompactionCard(props: CompactionCardProps) {
|
||||
const containerClass = () =>
|
||||
`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.stopPropagation()
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!canDeleteMessage()) return
|
||||
setDeletingMessage(true)
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeletingMessage(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)
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`delete-hover-scope ${containerClass()} relative`}
|
||||
class={`${containerClass()} relative`}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||
>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-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="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>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
||||
disabled={!canDelete()}
|
||||
onClick={handleDelete}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
</button>
|
||||
|
||||
<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" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
</div>
|
||||
@@ -1057,9 +827,6 @@ function CompactionCard(props: CompactionCardProps) {
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
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 value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
@@ -1104,42 +871,6 @@ function StepCard(props: StepCardProps) {
|
||||
|
||||
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 entries = [
|
||||
@@ -1170,83 +901,17 @@ function StepCard(props: StepCardProps) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} 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>
|
||||
|
||||
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
|
||||
{renderUsageChips(usage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-title">
|
||||
<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())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||
@@ -1273,21 +938,15 @@ interface ReasoningCardProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
showAgentMeta?: 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) {
|
||||
const { t } = useI18n()
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
const [deleting, setDeleting] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -1314,8 +973,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
return modelID
|
||||
}
|
||||
|
||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||
|
||||
const reasoningText = () => {
|
||||
const part = props.part as any
|
||||
if (!part) return ""
|
||||
@@ -1356,44 +1013,29 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
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.stopPropagation()
|
||||
if (!props.showDeleteMessage) return
|
||||
if (!canDeleteMessage()) return
|
||||
setDeletingMessage(true)
|
||||
if (!canDelete()) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeletingMessage(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)
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="delete-hover-scope message-reasoning-card">
|
||||
<div class="message-reasoning-card">
|
||||
<div class="message-reasoning-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1402,28 +1044,22 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label">
|
||||
<span class="message-reasoning-label-primary">
|
||||
<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>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
</span>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<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>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1444,31 +1080,16 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<Show when={hasDeleteTarget()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
onClick={handleDeleteUpTo}
|
||||
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||
onClick={handleDelete}
|
||||
disabled={!canDelete()}
|
||||
aria-label={t("messagePart.actions.deleteTitle")}
|
||||
title={t("messagePart.actions.deleteTitle")}
|
||||
>
|
||||
<DeleteUpToIcon />
|
||||
</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" />
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
@@ -1476,23 +1097,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</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()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { deleteMessagePart } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
||||
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -28,112 +18,15 @@ interface MessageItemProps {
|
||||
isQueued?: boolean
|
||||
parts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
showDeleteMessage?: boolean
|
||||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const { t } = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
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 [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
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 info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.error) return null
|
||||
@@ -274,8 +162,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
|
||||
const info = props.messageInfo
|
||||
const timeInfo = info?.time as { created: number; end?: number } | undefined
|
||||
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
|
||||
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
||||
}
|
||||
|
||||
const handleRevert = () => {
|
||||
@@ -302,30 +189,47 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
if (deletingMessage()) return
|
||||
setDeletingMessage(true)
|
||||
const deletableTextPartId = () => {
|
||||
const part = props.parts.find((candidate) => {
|
||||
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 {
|
||||
await deleteMessage(props.instanceId, props.sessionId, props.record.id)
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
||||
title: t("messagePart.actions.deleteFailedTitle"),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setDeletingMessage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUpTo = async () => {
|
||||
if (!props.onDeleteMessagesUpTo) return
|
||||
if (deletingUpTo()) return
|
||||
setDeletingUpTo(true)
|
||||
try {
|
||||
await props.onDeleteMessagesUpTo(props.record.id)
|
||||
} finally {
|
||||
setDeletingUpTo(false)
|
||||
setPartDeleting(partId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,16 +257,8 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
|
||||
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
|
||||
if (!base) return ""
|
||||
|
||||
const variant = (info as SDKAssistantMessageV2).variant
|
||||
if (typeof variant === "string" && variant.trim().length > 0) {
|
||||
return `${base} (${variant.trim()})`
|
||||
}
|
||||
|
||||
return base
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
const agentMeta = () => {
|
||||
@@ -381,68 +277,28 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<div class={containerClass()}>
|
||||
<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-header-left">
|
||||
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
|
||||
<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.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 class="message-item-header-row message-item-header-row--top">
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="message-item-actions" ref={(el) => (actionsEl = el)}>
|
||||
<div class="message-item-actions">
|
||||
<Show when={isUser()}>
|
||||
<div class="message-action-group">
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revert")}
|
||||
aria-label={t("messageItem.actions.revert")}
|
||||
>
|
||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<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" />
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title={t("messageItem.actions.revertTitle")}
|
||||
aria-label={t("messageItem.actions.revertTitle")}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title={copyLabel()}
|
||||
aria-label={copyLabel()}
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
@@ -503,30 +330,18 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<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 when={deletableTextPartId()}>
|
||||
{(partId) => (
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => void handleDeletePart(partId())}
|
||||
disabled={isDeletingPart(partId())}
|
||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -534,10 +349,12 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={metaText() && !showMetaInline()}>
|
||||
<div class="message-item-header-row message-item-header-row--meta">
|
||||
<span class="message-agent-meta-block">{metaText()}</span>
|
||||
</div>
|
||||
<Show when={agentMeta()}>
|
||||
{(meta) => (
|
||||
<div class="message-item-header-row message-item-header-row--bottom">
|
||||
<span class="message-agent-meta">{meta()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
</header>
|
||||
@@ -560,20 +377,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</Show>
|
||||
|
||||
<For each={messageParts()}>
|
||||
{(part) => {
|
||||
return (
|
||||
<div class="message-part-shell">
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
primaryUserTextPartId={primaryUserTextPartId()}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
primaryUserTextPartId={primaryUserTextPartId()}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
@@ -583,16 +396,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
@@ -620,6 +424,24 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</svg>
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}}
|
||||
@@ -627,31 +449,6 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</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"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import ContextMeter from "./context-meter"
|
||||
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 {
|
||||
usedTokens: number
|
||||
|
||||
@@ -19,6 +21,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
|
||||
return (
|
||||
<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-usage">
|
||||
<ContextMeter
|
||||
usedTokens={props.usedTokens}
|
||||
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
||||
formatTokens={props.formatTokens}
|
||||
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
||||
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
||||
/>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,14 +55,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button"
|
||||
class="connection-status-button"
|
||||
onClick={props.onCommandPalette}
|
||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||
>
|
||||
{t("messageListHeader.commandPalette.button")}
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import ToolCall from "./tool-call"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -16,18 +17,16 @@ interface MessagePartProps {
|
||||
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||
primaryUserTextPartId?: string | null
|
||||
onRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
const partType = () => props.part?.type || ""
|
||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||
const isAssistantMessage = () => props.messageType === "assistant"
|
||||
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 part = props.part
|
||||
@@ -58,11 +57,6 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
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 {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
@@ -97,28 +91,20 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const createTextPartForMarkdown = (): TextPart => {
|
||||
const part = props.part
|
||||
if (part.type === "text" && 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.
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: (part as any).text,
|
||||
synthetic: false,
|
||||
version: (part as { version?: number }).version,
|
||||
renderCache: (part as any).renderCache,
|
||||
text: part.text,
|
||||
synthetic: part.type === "text" ? part.synthetic : false,
|
||||
version: (part as { version?: number }).version
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
type: "text",
|
||||
text: "",
|
||||
synthetic: false,
|
||||
synthetic: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,23 +117,22 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
<Switch>
|
||||
<Match when={partType() === "text"}>
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div
|
||||
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||
data-role={textContainerRole()}
|
||||
data-part-type="text"
|
||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||
>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
interface MessagePreviewProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
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) => {
|
||||
@@ -30,11 +24,6 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
showUsageMetrics={() => false}
|
||||
deleteHover={props.deleteHover}
|
||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</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 { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||
|
||||
@@ -21,38 +19,18 @@ export interface TimelineSegment {
|
||||
shortLabel?: string
|
||||
variant?: "auto" | "manual"
|
||||
toolPartIds?: string[]
|
||||
partIds?: string[]
|
||||
partId?: string
|
||||
totalChars: number
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
onToggleSelection?: (id: string) => void
|
||||
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
|
||||
activeMessageId?: string | null
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
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 LONG_PRESS_MS = 500
|
||||
const JITTER_THRESHOLD = 10
|
||||
const ABSOLUTE_TOKEN_CAP = 10000
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
@@ -60,8 +38,10 @@ interface PendingSegment {
|
||||
type: TimelineSegmentType
|
||||
texts: string[]
|
||||
reasoningTexts: string[]
|
||||
partIds: string[]
|
||||
totalChars: number
|
||||
toolTitles: string[]
|
||||
toolTypeLabels: string[]
|
||||
toolIcons: string[]
|
||||
toolPartIds: string[]
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
@@ -191,13 +171,18 @@ export function buildTimelineSegments(
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
const label = segmentLabel(pending.type)
|
||||
const shortLabel = undefined
|
||||
const tooltip = formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||
)
|
||||
|
||||
const isToolSegment = pending.type === "tool"
|
||||
const label = isToolSegment
|
||||
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||
: segmentLabel(pending.type)
|
||||
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({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
@@ -205,24 +190,16 @@ export function buildTimelineSegments(
|
||||
label,
|
||||
tooltip,
|
||||
shortLabel,
|
||||
partIds: pending.partIds,
|
||||
totalChars: pending.totalChars,
|
||||
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
}
|
||||
|
||||
|
||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
pending = {
|
||||
type,
|
||||
texts: [],
|
||||
reasoningTexts: [],
|
||||
partIds: [],
|
||||
totalChars: 0,
|
||||
hasPrimaryText: type !== "assistant",
|
||||
}
|
||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||
}
|
||||
return pending!
|
||||
}
|
||||
@@ -234,21 +211,14 @@ export function buildTimelineSegments(
|
||||
if (!part || typeof part !== "object") continue
|
||||
|
||||
if (part.type === "tool") {
|
||||
flushPending()
|
||||
const target = ensureSegment("tool")
|
||||
const toolPart = part as ToolCallPart
|
||||
const partId = typeof toolPart.id === "string" ? toolPart.id : ""
|
||||
const title = getToolTitle(toolPart, t)
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.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
|
||||
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||
target.toolPartIds.push(toolPart.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -258,18 +228,13 @@ export function buildTimelineSegments(
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushPending()
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
@@ -277,8 +242,6 @@ export function buildTimelineSegments(
|
||||
label: segmentLabel("compaction"),
|
||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
partId,
|
||||
totalChars: 0,
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
@@ -287,23 +250,19 @@ export function buildTimelineSegments(
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
const text = collectTextFromPart(part, t)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.texts.push(text)
|
||||
target.hasPrimaryText = true
|
||||
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||
target.partIds.push((part as any).id)
|
||||
}
|
||||
target.totalChars += getPartCharCount(part)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flushPending()
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -319,14 +278,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
let hoverTimer: number | null = null
|
||||
let closeTimer: number | null = null
|
||||
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) => {
|
||||
if (element) {
|
||||
buttonRefs.set(segmentId, element)
|
||||
@@ -334,7 +286,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
buttonRefs.delete(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(hoverTimer)
|
||||
@@ -360,11 +312,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
setHoverAnchorRect(null)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
// Suppress previews during long-press selection gestures.
|
||||
if (longPressTimer !== null) return
|
||||
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
@@ -379,7 +328,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const handleMouseLeave = () => {
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const anchor = hoverAnchorRect()
|
||||
@@ -401,235 +350,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
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(() => {
|
||||
if (isSelectionActive()) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const activeId = props.activeMessageId
|
||||
|
||||
// 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
|
||||
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
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
@@ -639,7 +366,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const element = tooltipElement()
|
||||
@@ -656,265 +383,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
})
|
||||
|
||||
const previewData = createMemo(() => {
|
||||
|
||||
const segment = hoveredSegment()
|
||||
if (!segment) return null
|
||||
const record = store().getMessage(segment.messageId)
|
||||
if (!record) return null
|
||||
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 (
|
||||
<div class="message-timeline-container">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||
role="navigation"
|
||||
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)
|
||||
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
|
||||
const isDeleteHovered = () => {
|
||||
const hover = deleteHover() as DeleteHoverState
|
||||
if (hover.kind === "message") {
|
||||
return hover.messageId === segment.messageId
|
||||
}
|
||||
|
||||
if (hover.kind === "deleteUpTo") {
|
||||
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
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isDeleteSelected = () => {
|
||||
const selected = props.selectedMessageIds?.()
|
||||
if (!selected) return false
|
||||
return selected.has(segment.messageId)
|
||||
}
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
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")
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert 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 segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
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 (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""}`}
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
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" : ""}`}
|
||||
|
||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
if (wasLongPress) {
|
||||
wasLongPress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Capture scroll anchor before selection changes may toggle
|
||||
// tool segment visibility, which shifts timeline layout.
|
||||
const btn = buttonRefs.get(segment.id)
|
||||
let anchorOffset: number | null = null
|
||||
if (btn && scrollContainerRef) {
|
||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||
}
|
||||
|
||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||
|
||||
if (event.shiftKey) {
|
||||
props.onSelectRange?.(segment.id)
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
props.onToggleSelection?.(segment.id)
|
||||
} else if (isMultiSelectActive) {
|
||||
// In selection mode, plain click scrolls to the message
|
||||
// instead of clearing. Selection is cleared by clicking
|
||||
// anywhere inside the chat container or pressing Esc.
|
||||
props.onSegmentClick?.(segment)
|
||||
} else {
|
||||
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>
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
|
||||
const favoriteKeySet = createMemo(() => {
|
||||
const result = new Set<string>()
|
||||
for (const item of uiState().models.favorites ?? []) {
|
||||
for (const item of preferences().modelFavorites ?? []) {
|
||||
if (item.providerId && item.modelId) {
|
||||
result.add(`${item.providerId}/${item.modelId}`)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
serverSettings,
|
||||
updateLastUsedBinary,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
} = useConfig()
|
||||
const [customPath, setCustomPath] = createSignal("")
|
||||
const [validating, setValidating] = createSignal(false)
|
||||
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
const lastUsedBinary = () => serverSettings().opencodeBinary
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
|
||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||
|
||||
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
if (validation.valid) {
|
||||
addOpenCodeBinary(path, validation.version)
|
||||
props.onBinaryChange(path)
|
||||
updateLastUsedBinary(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
setCustomPath("")
|
||||
setValidationError(null)
|
||||
} else {
|
||||
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
if (props.disabled) return
|
||||
if (path === props.selectedBinary) return
|
||||
props.onBinaryChange(path)
|
||||
updateLastUsedBinary(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
}
|
||||
|
||||
function handleRemoveBinary(path: string, event: Event) {
|
||||
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
if (props.selectedBinary === path) {
|
||||
props.onBinaryChange("opencode")
|
||||
updateLastUsedBinary("opencode")
|
||||
updatePreferences({ lastUsedBinary: "opencode" })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||
@@ -14,41 +13,12 @@ import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import { usePromptState } from "./prompt-input/usePromptState"
|
||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
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) {
|
||||
const { t } = useI18n()
|
||||
const [, setIsFocused] = createSignal(false)
|
||||
@@ -206,26 +176,15 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
),
|
||||
)
|
||||
|
||||
const isCoarsePointer = () => {
|
||||
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
|
||||
|
||||
onMount(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
const isInputElement =
|
||||
activeElement?.tagName === "INPUT" ||
|
||||
activeElement?.tagName === "TEXTAREA" ||
|
||||
activeElement?.tagName === "SELECT" ||
|
||||
Boolean(activeElement?.isContentEditable)
|
||||
activeElement?.isContentEditable
|
||||
|
||||
if (isInputElement) return
|
||||
|
||||
@@ -233,25 +192,16 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (isModifierKey) return
|
||||
|
||||
const isSpecialKey =
|
||||
e.key === "Tab" ||
|
||||
e.key === "Enter" ||
|
||||
e.key.startsWith("Arrow") ||
|
||||
e.key === "Backspace" ||
|
||||
e.key === "Delete"
|
||||
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
|
||||
if (isSpecialKey) return
|
||||
|
||||
const textarea = textareaRef
|
||||
if (!textarea || textarea.disabled) return
|
||||
|
||||
// In session cache mode inactive panes are display:none; avoid stealing focus.
|
||||
if (textarea.offsetParent === null) return
|
||||
|
||||
if (e.key.length === 1) {
|
||||
textarea.focus()
|
||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
||||
textareaRef.focus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||
})
|
||||
@@ -276,12 +226,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
commandName.length > 0 &&
|
||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||
|
||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
||||
const resolvedPrompt = isKnownSlashCommand
|
||||
? resolvedCommandArgs
|
||||
? `${commandToken} ${resolvedCommandArgs}`
|
||||
: commandToken
|
||||
: resolvePastedPlaceholders(text, currentAttachments)
|
||||
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||
const historyEntry = resolvedPrompt
|
||||
|
||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||
@@ -297,10 +242,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
||||
for (const attachmentId of consumedIds) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
@@ -320,7 +261,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else if (isKnownSlashCommand) {
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, currentAttachments)
|
||||
}
|
||||
@@ -390,9 +331,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
// End the blockquote with a blank line so the user's next line
|
||||
// doesn't get parsed as a lazy continuation of the quote.
|
||||
insertBlockContent(`${blockquote}\n\n`)
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
@@ -496,7 +435,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={props.disabled}
|
||||
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
|
||||
rows={expandState() === "expanded" ? 15 : 4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autoCapitalize="off"
|
||||
@@ -541,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
|
||||
@@ -17,12 +17,6 @@ export interface PromptInputProps {
|
||||
instanceId: string
|
||||
instanceFolder: 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>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||
import type { Agent } from "../../types/session"
|
||||
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import type { PickerMode } from "./types"
|
||||
import type { PickerSelectAction } from "../unified-picker"
|
||||
|
||||
@@ -204,30 +204,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === "" || relativePath === "./"
|
||||
? "./"
|
||||
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
const normalizedFolderPath = (() => {
|
||||
const trimmed = relativePath.replace(/\/+$/, "")
|
||||
// If it's root "./", just return "./"
|
||||
if (trimmed === "" || trimmed === ".") return "./"
|
||||
// Otherwise remove any leading ./ and add ./ prefix
|
||||
return "./" + trimmed.replace(/^\.\//, "")
|
||||
return trimmed.length > 0 ? trimmed : "."
|
||||
})()
|
||||
|
||||
const addPathOnlyAttachment = (value: string) => {
|
||||
const display = `path: ${value}`
|
||||
const filename = value
|
||||
const existing = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existing.some(
|
||||
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
|
||||
)
|
||||
if (!alreadyAttached) {
|
||||
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
|
||||
}
|
||||
}
|
||||
|
||||
if (isFolder) {
|
||||
if (action === "tab") {
|
||||
// TAB on directory: autocomplete directory name and show its contents.
|
||||
@@ -239,14 +224,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
const mentionText = `@${folderMention}`
|
||||
|
||||
if (action === "shiftEnter") {
|
||||
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
||||
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
||||
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||
} else {
|
||||
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
||||
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||
const dirLabel =
|
||||
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
@@ -255,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
)
|
||||
|
||||
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(
|
||||
normalizedFolderPath,
|
||||
dirFilename,
|
||||
@@ -278,15 +275,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
}
|
||||
|
||||
if (action === "shiftEnter") {
|
||||
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||
addPathOnlyAttachment(normalizedPathWithPrefix)
|
||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||
} else {
|
||||
// ENTER/click on file: attach file (existing behavior).
|
||||
// Always prefix with ./ for consistency
|
||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||
const pathSegments = normalizedPath.split("/")
|
||||
const filename = (() => {
|
||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||
@@ -295,12 +287,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
|
||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(
|
||||
normalizedPathWithPrefix,
|
||||
normalizedPath,
|
||||
filename,
|
||||
"text/plain",
|
||||
undefined,
|
||||
@@ -309,7 +301,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
||||
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)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
// Clear ignoredAtPositions so typing @ again will work
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
}
|
||||
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 { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||
import { preferences, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -23,7 +23,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
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)
|
||||
@@ -34,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
|
||||
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 displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
@@ -89,10 +88,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (applyingListeningMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||
variant: "warning",
|
||||
@@ -105,21 +100,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingListeningMode(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Important: await the config patch before restart so Electron reads the updated mode from disk.
|
||||
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)
|
||||
setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError(t("remoteAccess.restart.errorManual"))
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
|
||||
void refreshMeta()
|
||||
@@ -210,7 +196,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
onChange={(nextChecked) => {
|
||||
void handleAllowConnectionsChange(nextChecked)
|
||||
}}
|
||||
disabled={loading() || applyingListeningMode()}
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
|
||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<kbd class="kbd ml-2 kbd-hint">
|
||||
<kbd class="kbd ml-2">
|
||||
Cmd+Enter
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { deleteMessage } from "../../stores/session-actions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
@@ -29,8 +28,6 @@ interface SessionViewProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
isPhoneLayout?: boolean
|
||||
compactPromptLayout?: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
@@ -56,22 +53,12 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
|
||||
let promptInputApi: PromptInputApi | null = null
|
||||
let pendingPromptText: string | null = null
|
||||
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||
|
||||
let scrollToBottomHandle: (() => void) | 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() {
|
||||
if (!scrollToBottomHandle) return
|
||||
requestAnimationFrame(() => {
|
||||
@@ -80,7 +67,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!props.isActive) return
|
||||
if (!shouldScrollToBottomOnActivate()) return
|
||||
scheduleScrollToBottom()
|
||||
})
|
||||
|
||||
@@ -90,9 +76,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
(isActive) => {
|
||||
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.)
|
||||
if (typeof document === "undefined") return
|
||||
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) {
|
||||
if (!messageId) {
|
||||
log.warn("Fork requires a user message id")
|
||||
@@ -324,17 +278,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
<MessageSection
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
isActive={props.isActive}
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
if (props.isActive) {
|
||||
if (shouldScrollToBottomOnActivate()) {
|
||||
scheduleScrollToBottom()
|
||||
}
|
||||
scheduleScrollToBottom()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -363,19 +314,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
isActive={props.isActive}
|
||||
compactLayout={props.compactPromptLayout}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={activeSession.id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
</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