Compare commits

..

16 Commits

Author SHA1 Message Date
Shantur Rathore
0e5695a903 ui: emphasize command palette button 2026-02-20 00:24:24 +00:00
Shantur Rathore
77103b7292 ui: use Check icon for completed status 2026-02-20 00:14:02 +00:00
Shantur Rathore
b14a144ddc ui: use lucide status icons for tool calls 2026-02-20 00:08:07 +00:00
Shantur Rathore
8ac67311d8 ui: use emoji status icons for tool calls 2026-02-19 23:51:33 +00:00
Shantur Rathore
0c97db393c fix(ui): expand read tool calls on error 2026-02-19 21:16:14 +00:00
Shantur Rathore
614c300d2f ui: default tool input visibility to collapsed 2026-02-19 21:12:39 +00:00
Shantur Rathore
e6ca4bd43d fix(ui): let palette tool input visibility override per-call 2026-02-19 20:46:46 +00:00
Shantur Rathore
84f81cf829 ui: left-align tool IO header text 2026-02-19 20:44:22 +00:00
Shantur Rathore
3760ba2d7f fix(ui): scope tool input toggle to current tool call 2026-02-19 20:42:23 +00:00
Shantur Rathore
09e7a3f8da feat(ui): add tool input visibility preference 2026-02-19 20:37:48 +00:00
Shantur Rathore
c55d56c94b ui: remove semibold from IO headers 2026-02-19 18:44:57 +00:00
Shantur Rathore
cc53123bcd ui: remove extra padding around IO sections 2026-02-19 18:44:29 +00:00
Shantur Rathore
d64027d43d ui: refine tool IO accordion styling 2026-02-19 18:43:06 +00:00
Shantur Rathore
6b7162f50f ui: add input/output accordions in tool calls 2026-02-19 18:37:46 +00:00
Shantur Rathore
5fd985f0c2 ui: rename tool input toggle and add IO headers 2026-02-19 18:31:41 +00:00
Shantur Rathore
2a438b2bb3 feat(ui): toggle tool call input yaml 2026-02-19 18:09:16 +00:00
158 changed files with 4392 additions and 10767 deletions

View File

@@ -3,11 +3,6 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -28,21 +23,6 @@ on:
required: false required: false
default: true default: true
type: boolean type: boolean
upload_actions_artifacts:
description: "Upload built artifacts to GitHub Actions run artifacts"
required: false
default: false
type: boolean
actions_artifacts_retention_days:
description: "Retention (days) for GitHub Actions artifacts"
required: false
default: 7
type: number
actions_artifacts_name_prefix:
description: "Optional prefix for Actions artifact names"
required: false
default: ""
type: string
set_versions: set_versions:
description: "Run npm version to set workspace versions" description: "Run npm version to set workspace versions"
required: false required: false
@@ -65,8 +45,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -76,21 +54,7 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
shell: bash run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
run: |
set -euo pipefail
for attempt in 1 2 3; do
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
exit 0
fi
echo "npm version failed (attempt $attempt/3); retrying..." >&2
sleep $((attempt * 10))
done
exit 1
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@@ -101,112 +65,6 @@ jobs:
- name: Build macOS binaries (Electron) - name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
shell: bash
run: |
set -euo pipefail
release_root="packages/electron-app/release"
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
# GitHub macOS runners typically have no signing identity. Without any signature,
# the shipped .app can fail Gatekeeper with:
# code has no resources but signature indicates they must be present
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
for app in "${apps[@]}"; do
echo "codesign (adhoc): $app"
codesign --force --deep --sign - "$app"
codesign --verify --deep --strict --verbose=2 "$app"
done
else
echo "macOS codesigning identity present; skipping ad-hoc signing"
fi
- name: Repackage Electron macOS zips (ditto)
shell: bash
run: |
set -euo pipefail
# Prefer the workflow-provided version; fall back to package.json.
VERSION_TO_USE="${VERSION:-}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
fi
release_root="packages/electron-app/release"
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
# Use find to locate built app bundles instead of ** globs.
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
for app in "${apps[@]}"; do
bundle_dir=$(basename "$(dirname "$app")")
arch="x64"
if [[ "$bundle_dir" == *"arm64"* ]]; then
arch="arm64"
fi
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
rm -f "$out_zip"
echo "ditto -ck: $app -> $out_zip"
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
done
- name: Validate Electron macOS codesign (unzipped)
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
if [ "${#zips[@]}" -eq 0 ]; then
echo "No Electron macOS zip artifacts found to validate" >&2
exit 1
fi
for zip in "${zips[@]}"; do
echo "Validating codesign for: $zip"
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
mkdir -p "$extract_dir"
# Use ditto for extraction as well to preserve bundle metadata.
ditto -x -k "$zip" "$extract_dir"
app_path=""
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
if [ -d "$candidate" ]; then
app_path="$candidate"
break
fi
done
if [ -z "$app_path" ]; then
echo "No .app found after extracting $zip" >&2
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$app_path"
done
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -218,15 +76,6 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-windows: build-windows:
runs-on: windows-2025 runs-on: windows-2025
env: env:
@@ -236,8 +85,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -268,15 +115,6 @@ jobs:
gh release upload $env:TAG $_.FullName --clobber gh release upload $env:TAG $_.FullName --clobber
} }
- name: Upload Actions artifacts (Electron Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-linux: build-linux:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env: env:
@@ -286,8 +124,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -319,15 +155,6 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-tauri-macos: build-tauri-macos:
runs-on: macos-15-intel runs-on: macos-15-intel
env: env:
@@ -337,8 +164,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -381,7 +206,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS) - name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -392,15 +217,6 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS) - name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -421,8 +237,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -465,7 +279,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64) - name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -476,15 +290,6 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS arm64)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS arm64) - name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -505,8 +310,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -552,7 +355,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows) - name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
shell: pwsh shell: pwsh
run: | run: |
$bundleRoot = "packages/tauri-app/target/release/bundle" $bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -565,15 +368,6 @@ jobs:
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
} }
- name: Upload Actions artifacts (Tauri Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Windows) - name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh shell: pwsh
@@ -594,8 +388,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -651,7 +443,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux) - name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload || inputs.upload_actions_artifacts }} if: ${{ inputs.upload }}
run: | run: |
set -euo pipefail set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target" SEARCH_ROOT="packages/tauri-app/target"
@@ -677,15 +469,6 @@ jobs:
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb" cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm" cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/*
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Linux) - name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -707,8 +490,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -806,8 +587,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -844,12 +623,3 @@ jobs:
echo "Uploading $file" echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux RPM)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
path: packages/electron-app/release/*.rpm
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

@@ -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}`);

View File

@@ -1,13 +1,12 @@
name: Develop Pre-Release name: Develop Pre-Release
on: on:
schedule: push:
# Nightly build of dev (only if dev has new commits) branches:
- cron: "0 1 * * *" - dev
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
id-token: write id-token: write
contents: write contents: write
@@ -16,63 +15,25 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
gate: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
run: ${{ steps.gate.outputs.run }} version_suffix: ${{ steps.vars.outputs.version_suffix }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps: steps:
- name: Decide whether to run - name: Compute version suffix
id: gate id: vars
shell: bash shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
SHA8="${GITHUB_SHA::8}"
api() {
curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$1"
}
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
echo "Failed to resolve dev head SHA" >&2
exit 1
fi
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
SHA8="${DEV_SHA::8}" echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT"
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
SHOULD_RUN="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
SHOULD_RUN="true"
else
# Nightly: only run if dev has advanced since last successful dev-release build.
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
if [ -z "${LAST_SHA}" ]; then
SHOULD_RUN="true"
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
SHOULD_RUN="true"
fi
fi
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
prerelease: prerelease:
needs: gate needs: prepare
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
ref: ${{ needs.gate.outputs.dev_sha }} version_suffix: ${{ needs.prepare.outputs.version_suffix }}
version_suffix: ${{ needs.gate.outputs.version_suffix }}
npm_package_name: "@neuralnomads/codenomad-dev" npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest dist_tag: latest
prerelease: true prerelease: true

View File

@@ -19,10 +19,6 @@ on:
type: string type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -50,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -3,11 +3,6 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -51,8 +46,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -91,7 +84,6 @@ jobs:
needs: prepare-release needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }} tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }} release_name: ${{ needs.prepare-release.outputs.release_name }}
@@ -103,8 +95,6 @@ jobs:
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
with:
ref: ${{ inputs.ref || github.ref }}
secrets: inherit secrets: inherit
publish-server: publish-server:
@@ -113,7 +103,6 @@ jobs:
- build-and-upload - build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }} package_name: ${{ inputs.npm_package_name }}

87
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -3253,9 +3253,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.10.1", "version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3305,32 +3305,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": { "node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -10244,6 +10218,14 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10984,36 +10966,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
@@ -12033,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12043,7 +11995,6 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
@@ -12070,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12111,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.12.3", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12119,7 +12070,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.3", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
@@ -12129,8 +12080,6 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
@@ -12143,7 +12092,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8", "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.12.3", "version": "0.11.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",
@@ -31,4 +31,4 @@
"devDependencies": { "devDependencies": {
"baseline-browser-mapping": "^2.9.11" "baseline-browser-mapping": "^2.9.11"
} }
} }

View File

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

View File

@@ -1,5 +1,4 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs"
import type { CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null let wakeLockId: number | null = null
@@ -66,24 +65,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths } return { canceled: result.canceled, paths: result.filePaths }
}) })
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
if (!Array.isArray(paths)) {
return []
}
const directories = paths.filter((value): value is string => {
if (typeof value !== "string" || value.trim().length === 0) {
return false
}
try {
return fs.statSync(value).isDirectory()
} catch {
return false
}
})
return directories
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled) const next = Boolean(enabled)
if (next) { if (next) {

View File

@@ -431,9 +431,7 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) { if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim() args.push("--ui-dev-server", devServer, "--log-level", "debug")
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
} }
return args return args

View File

@@ -1,4 +1,4 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron") const { contextBridge, ipcRenderer } = require("electron")
const electronAPI = { const electronAPI = {
onCliStatus: (callback) => { onCliStatus: (callback) => {
@@ -12,14 +12,6 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"), restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options), openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file)
} catch {
return null
}
},
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3", "version": "0.11.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -15,10 +15,7 @@
}, },
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "npm run dev:info", "dev": "electron-vite dev",
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -45,7 +42,6 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

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

View File

@@ -5,21 +5,18 @@
## Features & Capabilities ## Features & Capabilities
### 🌍 Deployment Freedom ### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. - **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. - **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. - **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect. - **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power ### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches. - **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts. - **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites ## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system. - **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source). - Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve. - A workspace folder on disk you want to serve.
@@ -28,7 +25,6 @@
## Usage ## Usage
### Run via npx (Recommended) ### Run via npx (Recommended)
You can run CodeNomad directly without installing it: You can run CodeNomad directly without installing it:
```sh ```sh
@@ -47,7 +43,6 @@ On startup, CodeNomad prints two URLs:
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) - `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally ### Install Globally
Or install it globally to use the `codenomad` command: Or install it globally to use the `codenomad` command:
```sh ```sh
@@ -56,7 +51,6 @@ codenomad --launch
``` ```
### Install Locally (per-project) ### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary: If you prefer to install CodeNomad into a project and run the local binary:
```sh ```sh
@@ -67,7 +61,6 @@ npx codenomad --launch
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.) (`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags ### Common Flags
You can configure the server using flags or environment variables: You can configure the server using flags or environment variables:
| Flag | Env Variable | Description | | Flag | Env Variable | Description |
@@ -81,7 +74,7 @@ You can configure the server using flags or environment variables:
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. | | `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
@@ -94,11 +87,10 @@ You can configure the server using flags or environment variables:
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` | | `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | | `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced) ### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh ```sh
@@ -149,14 +141,12 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
``` ```
### Authentication ### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA) ### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
@@ -168,6 +158,5 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). > If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.12.3", "version": "0.11.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -46,4 +46,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption( .addOption(
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()), new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
) )
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))

View File

@@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js" import { getOpencodeConfigDir } from "../opencode-config.js"
import { import {
@@ -109,6 +109,10 @@ export class WorkspaceManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
@@ -145,10 +149,7 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
if (runtimeVersion) {
descriptor.binaryVersion = runtimeVersion
}
descriptor.pid = pid descriptor.pid = pid
descriptor.port = port descriptor.port = port
@@ -277,12 +278,36 @@ export class WorkspaceManager {
return candidates[0] ?? "" return candidates[0] ?? ""
} }
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
const result = probeBinaryVersion(resolvedPath)
if (result.valid) {
if (result.version) {
this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
return result.version
}
if (result.reported) {
this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
return result.reported
}
return undefined
}
if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: { private async waitForWorkspaceReadiness(params: {
workspaceId: string workspaceId: string
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
await Promise.race([ await Promise.race([
this.waitForPortAvailability(params.port), this.waitForPortAvailability(params.port),
@@ -296,7 +321,7 @@ export class WorkspaceManager {
}), }),
]) ])
const version = await this.waitForInstanceHealth(params) await this.waitForInstanceHealth(params)
await Promise.race([ await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS), this.delay(STARTUP_STABILITY_DELAY_MS),
@@ -309,8 +334,6 @@ export class WorkspaceManager {
) )
}), }),
]) ])
return version
} }
private async waitForInstanceHealth(params: { private async waitForInstanceHealth(params: {
@@ -318,7 +341,7 @@ export class WorkspaceManager {
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}): Promise<string | undefined> { }) {
const probeResult = await Promise.race([ const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port), this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => { params.exitPromise.then((info) => {
@@ -332,7 +355,7 @@ export class WorkspaceManager {
]) ])
if (probeResult.ok) { if (probeResult.ok) {
return probeResult.version return
} }
const latestOutput = params.getLastOutput().trim() const latestOutput = params.getLastOutput().trim()
@@ -343,11 +366,8 @@ export class WorkspaceManager {
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
} }
private async probeInstance( private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
workspaceId: string, const url = `http://127.0.0.1:${port}/project/current`
port: number,
): Promise<{ ok: boolean; reason?: string; version?: string }> {
const url = `http://127.0.0.1:${port}/global/health`
try { try {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -358,22 +378,11 @@ export class WorkspaceManager {
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `/global/health returned HTTP ${response.status}` const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason } return { ok: false, reason }
} }
return { ok: true }
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
const healthy = payload?.healthy === true
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
if (!healthy) {
const reason = "Instance reported unhealthy"
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
return { ok: false, reason }
}
return { ok: true, version: version || undefined }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error) const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -20,7 +20,6 @@ const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" "npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand = const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" "npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const envWithRootBin = { const envWithRootBin = {
...process.env, ...process.env,
@@ -92,15 +91,6 @@ function ensureUiBuild() {
} }
} }
function syncServerUiBundle() {
console.log("[prebuild] syncing server public UI bundle...")
execSync(serverPrepareUiCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDevDependencies() { function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) { if (fs.existsSync(braceExpansionPath)) {
return return
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
ensureServerDependencies() ensureServerDependencies()
ensureServerBuild() ensureServerBuild()
ensureUiBuild() ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts() copyServerArtifacts()
stripNodeModuleBins() stripNodeModuleBins()
copyUiLoadingAssets() copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.12.3" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -19,12 +19,9 @@ thiserror = "1"
anyhow = "1" anyhow = "1"
which = "4" which = "4"
libc = "0.2" libc = "0.2"
keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
url = "2" url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }

View File

@@ -11,7 +11,6 @@
"core:menu:default", "core:menu:default",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-default-urls", "opener:allow-default-urls",
"opener:allow-open-url",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:allow-notify", "notification:allow-notify",

File diff suppressed because one or more lines are too long

View File

@@ -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"]}}

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -9,8 +9,6 @@ use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -19,24 +17,10 @@ use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> { fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| { std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 { for _ in 0..3 {
@@ -52,46 +36,6 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) { fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") { if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string(); let mut display = url.to_string();
@@ -404,19 +348,11 @@ impl CliProcessManager {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; libc::kill(child.id() as i32, libc::SIGTERM);
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !kill_process_tree_windows(child.id(), false) { let _ = child.kill();
let _ = child.kill();
}
} }
let start = Instant::now(); let start = Instant::now();
@@ -432,17 +368,11 @@ impl CliProcessManager {
)); ));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; libc::kill(child.id() as i32, libc::SIGKILL);
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !kill_process_tree_windows(child.id(), true) { let _ = child.kill();
let _ = child.kill();
}
} }
break; break;
} }
@@ -520,12 +450,9 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
ShellCommandType::Direct(cmd) => { ShellCommandType::Direct(cmd) => {
@@ -535,12 +462,9 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
}; };
@@ -613,24 +537,7 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string()); locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness"); log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() { if let Some(child) = child_holder_clone.lock().as_mut() {
#[cfg(unix)] let _ = child.kill();
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
} }
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked); Self::emit_status(&app_clone, &locked);
@@ -921,31 +828,14 @@ impl CliEntry {
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: plain HTTP + Vite dev server proxy.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var("ELECTRON_RENDERER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "http://localhost:3000".to_string());
let log_level = std::env::var("CLI_LOG_LEVEL")
.ok()
.map(|value| value.trim().to_lowercase())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string()); args.push("--https".to_string());
args.push("false".to_string()); args.push("false".to_string());
args.push("--http".to_string()); args.push("--http".to_string());
args.push("true".to_string()); args.push("true".to_string());
args.push("--http-port".to_string());
args.push("0".to_string());
args.push("--ui-dev-server".to_string()); args.push("--ui-dev-server".to_string());
args.push(ui_dev_server); args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string()); args.push("--log-level".to_string());
args.push(log_level); args.push("debug".to_string());
} else { } else {
// Prod desktop: always keep loopback HTTP enabled. // Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string()); args.push("--https".to_string());
@@ -1010,11 +900,6 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
let resources = dir.join("../Resources"); let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js"))); candidates.push(Some(resources.join("server/dist/index.js")));
@@ -1110,18 +995,9 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
} }
fn normalize_path(path: PathBuf) -> String { fn normalize_path(path: PathBuf) -> String {
let resolved = if let Ok(clean) = path.canonicalize() { if let Ok(clean) = path.canonicalize() {
clean clean.to_string_lossy().to_string()
} else { } else {
path path.to_string_lossy().to_string()
};
let rendered = resolved.to_string_lossy().to_string();
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
format!("\\\\{}", stripped)
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
stripped.to_string()
} else {
rendered
} }
} }

View File

@@ -3,11 +3,8 @@
mod cli_manager; mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus}; use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
@@ -15,31 +12,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(windows)]
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)] #[derive(Clone)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
display: bool,
idle: bool,
sleep: bool,
} }
#[tauri::command] #[tauri::command]
@@ -58,38 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status()) Ok(state.manager.status())
} }
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
sleep: false,
});
let mut builder = keepawake::Builder::default();
builder
.display(config.display)
.idle(config.idle)
.sleep(config.sleep)
.reason("CodeNomad active session")
.app_name("CodeNomad")
.app_reverse_domain("ai.neuralnomads.codenomad.client");
let wake_lock = builder.create().map_err(|err| err.to_string())?;
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
*state_lock = Some(wake_lock);
Ok(())
}
#[tauri::command]
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
state_lock.take();
Ok(())
}
fn is_dev_mode() -> bool { fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
@@ -101,10 +46,7 @@ fn should_allow_internal(url: &Url) -> bool {
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`. // On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will // This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank. // redirect it to the system browser and the app will appear blank.
"http" | "https" => matches!( "http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
url.host_str(),
Some("127.0.0.1" | "localhost" | "tauri.localhost")
),
_ => false, _ => false,
} }
} }
@@ -124,55 +66,6 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false false
} }
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
.filter_map(|path| match std::fs::metadata(path) {
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
_ => None,
})
.collect()
}
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, ());
}
}
fn emit_folder_drop_event(
app_handle: &AppHandle,
window_label: &str,
event_name: &str,
paths: &[std::path::PathBuf],
) {
let directories = collect_directory_paths(paths);
if directories.is_empty() {
return;
}
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, json!({ "paths": directories }));
}
}
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
.encode_wide()
.chain(iter::once(0))
.collect();
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
if result < 0 {
eprintln!("[tauri] failed to set AppUserModelID: {result}");
}
}
#[cfg(not(windows))]
fn set_windows_app_user_model_id() {}
fn main() { fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard") let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url)) .on_navigation(|webview, url| intercept_navigation(webview, url))
@@ -181,14 +74,13 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
let dev_mode = is_dev_mode(); let dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
@@ -200,12 +92,7 @@ fn main() {
}); });
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {
match event.id().0.as_str() { match event.id().0.as_str() {
// File menu // File menu
@@ -300,27 +187,6 @@ fn main() {
app.exit(0); app.exit(0);
}); });
} }
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
..
} => {
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
}
tauri::RunEvent::WindowEvent { tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::CloseRequested { api, .. }, event: tauri::WindowEvent::CloseRequested { api, .. },
.. ..
@@ -368,16 +234,13 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
"new_instance", "new_instance",
"New Instance", "New Instance",
true, true,
Some("CmdOrCtrl+N"), Some("CmdOrCtrl+N")
)?; )?;
let file_menu = SubmenuBuilder::new(app, "File") let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item) .item(&new_instance_item)
.separator() .separator()
.text( .text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
if is_mac { "close" } else { "quit" },
if is_mac { "Close" } else { "Quit" },
)
.build()?; .build()?;
submenus.push(file_menu); submenus.push(file_menu);
@@ -400,6 +263,7 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
.text("force_reload", "Force Reload") .text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools") .text("toggle_devtools", "Toggle Developer Tools")
.separator() .separator()
.separator() .separator()
.text("toggle_fullscreen", "Toggle Full Screen") .text("toggle_fullscreen", "Toggle Full Screen")
.build()?; .build()?;
@@ -413,12 +277,9 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
submenus.push(window_menu); submenus.push(window_menu);
// Build the main menu with all submenus // Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
.iter()
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
.collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?; let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?; app.set_menu(menu)?;
Ok(()) Ok(())
} }

View File

@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.12.3", "version": "0.1.0",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.opencode.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server", "beforeBuildCommand": "npm run bundle:server",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.12.3", "version": "0.11.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -18,10 +18,8 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-notification": "^2.3.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
@@ -32,7 +30,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8", "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
@@ -45,4 +43,4 @@
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
} }
} }

View File

@@ -9,15 +9,15 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs" import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2" import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen" import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
@@ -52,11 +52,11 @@ import {
} from "./stores/sessions" } from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status" import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
@@ -75,6 +75,14 @@ const App: Component = () => {
setToolInputsVisibility, setToolInputsVisibility,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)") const phoneQuery = useMediaQuery("(max-width: 767px)")
@@ -180,6 +188,10 @@ const App: Component = () => {
} }
}) })
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => { createEffect(() => {
initReleaseNotifications() initReleaseNotifications()
}) })
@@ -233,6 +245,35 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? "" const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) { if (!folderPath) {
return return
@@ -244,15 +285,20 @@ const App: Component = () => {
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", { log.info("Created instance", {
instanceId, instanceId,
port: instances().get(instanceId)?.port, port: instances().get(instanceId)?.port,
}) })
} catch (error) { } catch (error) {
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage")) const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message) const missingBinary = isMissingBinaryMessage(message)
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary }) setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error) log.error("Failed to create instance", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
@@ -265,7 +311,7 @@ const App: Component = () => {
function handleLaunchErrorAdvanced() { function handleLaunchErrorAdvanced() {
clearLaunchError() clearLaunchError()
openSettings("opencode") setIsAdvancedSettingsOpen(true)
} }
function handleNewInstanceRequest() { function handleNewInstanceRequest() {
@@ -478,6 +524,7 @@ const App: Component = () => {
onSelect={setActiveInstanceId} onSelect={setActiveInstanceId}
onClose={handleCloseInstance} onClose={handleCloseInstance}
onNew={handleNewInstanceRequest} onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/> />
</Show> </Show>
@@ -486,24 +533,17 @@ const App: Component = () => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection() const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div <div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
class="flex-1 min-h-0 overflow-hidden" <InstanceMetadataProvider instance={instance}>
style={{ display: isVisible() ? "flex" : "none" }} <InstanceShell
data-instance-id={instance.id} instance={instance}
data-instance-active={isActiveInstance() ? "true" : "false"} escapeInDebounce={escapeInDebounce()}
data-instance-visible={isVisible() ? "true" : "false"} paletteCommands={paletteCommands}
> onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
<InstanceMetadataProvider instance={instance}> onNewSession={() => handleNewSession(instance.id)}
<InstanceShell handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
instance={instance} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
isActiveInstance={isActiveInstance()} onExecuteCommand={executeCommand}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()} onEnterMobileFullscreen={() => void enterMobileFullscreen()}
@@ -523,6 +563,10 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/> />
</Show> </Show>
@@ -532,8 +576,12 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => { onClose={() => {
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError() clearLaunchError()
}} }}
/> />
@@ -541,7 +589,7 @@ const App: Component = () => {
</div> </div>
</Show> </Show>
<SettingsScreen /> <RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog /> <AlertDialog />

View File

@@ -116,8 +116,11 @@ const AlertDialog: Component = () => {
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}> <Dialog.Content
<div class="flex items-start gap-3"> class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
tabIndex={-1}
>
<div class="flex items-start gap-3 min-h-0">
<div <div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold" class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{ style={{
@@ -129,11 +132,16 @@ const AlertDialog: Component = () => {
> >
{accent.symbol} {accent.symbol}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 min-h-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title> <Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words"> <Dialog.Description class="text-sm text-secondary mt-1">
{payload.message} <div
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>} class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
style={{ "overflow-wrap": "anywhere" }}
>
{payload.message}
{payload.detail && <div class="mt-3">{payload.detail}</div>}
</div>
</Dialog.Description> </Dialog.Description>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,7 @@
import { createSignal, onMount, Show, createEffect } from "solid-js" import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter } from "../lib/markdown" import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"

View File

@@ -1,10 +1,9 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js" import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid" import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core" import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core" import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js" import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/text-render-utils" import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils" import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache" import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache" import type { CacheEntryParams } from "../lib/global-cache"
@@ -135,4 +134,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</Show> </Show>
</div> </div>
) )
} }

View File

@@ -61,11 +61,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4, lineNumbersMinChars: 4,
lineDecorationsWidth: 12, lineDecorationsWidth: 12,
// Use legacy diff algorithm for better performance with large files
// See: https://github.com/microsoft/vscode/issues/184037
diffAlgorithm: "legacy",
// Limit computation time to avoid freezing on large files
maxComputationTime: 10000,
}) })
setReady(true) setReady(true)

View File

@@ -2,32 +2,32 @@ import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill" import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars" import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters" import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n" import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void onClose?: () => void
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig() const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
@@ -193,31 +193,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}) })
}) })
function dropTargetBlocked() {
return isLoading() || isFolderBrowserOpen() || settingsOpen()
}
function showInvalidFolderDropAlert() {
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
title: t("folderSelection.drop.invalidTitle"),
variant: "warning",
})
}
const folderDrop = useFolderDrop({
enabled: () => !dropTargetBlocked(),
onInvalidDrop: showInvalidFolderDropAlert,
onDrop: async (paths) => {
const firstPath = paths[0]
if (!firstPath) {
showInvalidFolderDropAlert()
return
}
handleFolderSelect(firstPath)
},
})
function formatRelativeTime(timestamp: number): string { function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000) const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
@@ -235,6 +210,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
@@ -257,6 +237,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleFolderSelect(path) handleFolderSelect(path)
} }
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
function handleRemove(path: string, e?: Event) { function handleRemove(path: string, e?: Event) {
if (isLoading()) return if (isLoading()) return
e?.stopPropagation() e?.stopPropagation()
@@ -332,10 +317,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div <div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative" class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
style="background-color: var(--surface-secondary)" style="background-color: var(--surface-secondary)"
onDragEnter={folderDrop.bind.onDragEnter}
onDragOver={folderDrop.bind.onDragOver}
onDragLeave={folderDrop.bind.onDragLeave}
onDrop={folderDrop.bind.onDrop}
> >
<div <div
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden" class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
@@ -386,24 +367,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select> </Select>
</div> </div>
<div class="absolute top-4 right-6 flex items-center gap-2"> <div class="absolute top-4 right-6 flex items-center gap-2">
<button <ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
type="button" <Show when={props.onOpenRemoteAccess}>
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" <button
onClick={() => openSettings("appearance")} type="button"
aria-label={t("settings.open.title")} class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
title={t("settings.open.title")} onClick={() => props.onOpenRemoteAccess?.()}
> >
<Settings class="w-4 h-4" /> <MonitorUp class="w-4 h-4" />
</button> </button>
<button </Show>
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}> <Show when={props.onClose}>
<button <button
type="button" type="button"
@@ -423,7 +396,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1> <h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2"> <div class="mt-3 flex justify-center gap-2">
<a <a
href={GITHUB_URL} href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -431,13 +404,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.github")} title={t("folderSelection.links.github")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}} }}
> >
<GitHubMarkIcon class="w-4 h-4" /> <GitHubMarkIcon class="w-4 h-4" />
</a> </a>
<a <a
href={GITHUB_URL} href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
@@ -445,7 +418,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.githubStars")} title={t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}} }}
> >
<Star class="w-4 h-4" /> <Star class="w-4 h-4" />
@@ -454,7 +427,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show> </Show>
</a> </a>
<a <a
href={DISCORD_URL} href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -462,7 +435,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.discord")} title={t("folderSelection.links.discord")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(DISCORD_URL, "folder-selection") openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}} }}
> >
<DiscordSymbolIcon class="w-4 h-4" /> <DiscordSymbolIcon class="w-4 h-4" />
@@ -589,12 +564,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button> </button>
</div> </div>
{/* OpenCode settings section */} {/* Advanced settings section */}
<div class="panel-section w-full"> <div class="panel-section w-full">
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between"> <button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" /> <Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span> <span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
</div> </div>
<ChevronRight class="w-4 h-4 icon-muted" /> <ChevronRight class="w-4 h-4 icon-muted" />
</button> </button>
@@ -644,17 +619,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
<div class="folder-drop-overlay" aria-hidden="true">
<div class="folder-drop-card">
<FolderPlus class="w-8 h-8 icon-muted" />
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
</div>
</div>
</Show>
</div> </div>
<AdvancedSettingsModal
open={Boolean(props.advancedSettingsOpen)}
onClose={() => props.onAdvancedSettingsClose?.()}
selectedBinary={selectedBinary()}
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<DirectoryBrowserDialog <DirectoryBrowserDialog
open={isFolderBrowserOpen()} open={isFolderBrowserOpen()}
title={t("folderSelection.dialog.title")} title={t("folderSelection.dialog.title")}

View File

@@ -1,14 +1,15 @@
import { Component, For, Show, createMemo } from "solid-js" import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab" import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid" import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
interface InstanceTabsProps { interface InstanceTabsProps {
instances: Map<string, Instance> instances: Map<string, Instance>
@@ -16,11 +17,13 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void onClose: (instanceId: string) => void
onNew: () => void onNew: () => void
onOpenRemoteAccess?: () => void
} }
const InstanceTabs: Component<InstanceTabsProps> = (props) => { const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const { preferences } = useConfig() const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync()) const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled)) const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
@@ -30,10 +33,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
}) })
const notificationTitle = createMemo(() => { const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return t("settings.notifications.status.unsupported") if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
? t("settings.notifications.status.enabled")
: t("settings.notifications.status.disabled")
}) })
return ( return (
@@ -71,35 +72,32 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
/> />
</div> </div>
</Show> </Show>
<button <ThemeModeToggle class="new-tab-button" />
class="new-tab-button"
onClick={() => openSettings("appearance")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
<Settings class="w-4 h-4" />
</button>
<button <button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`} class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => openSettings("notifications")} onClick={() => setNotificationsOpen(true)}
title={notificationTitle()} title={notificationTitle()}
aria-label={notificationTitle()} aria-label={notificationTitle()}
> >
<Dynamic component={notificationIcon()} class="w-4 h-4" /> <Dynamic component={notificationIcon()} class="w-4 h-4" />
</button> </button>
<button <Show when={Boolean(props.onOpenRemoteAccess)}>
class="new-tab-button tab-remote-button" <button
onClick={() => openSettings("remote")} class="new-tab-button tab-remote-button"
title={t("instanceTabs.remote.title")} onClick={() => props.onOpenRemoteAccess?.()}
aria-label={t("instanceTabs.remote.ariaLabel")} title={t("instanceTabs.remote.title")}
> aria-label={t("instanceTabs.remote.ariaLabel")}
<MonitorUp class="w-4 h-4" /> >
</button> <MonitorUp class="w-4 h-4" />
</button>
</Show>
</div> </div>
</div> </div>
</div> </div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div> </div>
) )

View File

@@ -62,9 +62,6 @@ const log = getLogger("session")
interface InstanceShellProps { interface InstanceShellProps {
instance: Instance instance: Instance
// Provided by App-level instance tabs; lets us pause heavy rendering
// work for inactive instances while keeping them mounted for fast switching.
isActiveInstance?: boolean
escapeInDebounce: boolean escapeInDebounce: boolean
paletteCommands: Accessor<Command[]> paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void onCloseSession: (sessionId: string) => Promise<void> | void
@@ -118,7 +115,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)") const desktopQuery = useMediaQuery("(min-width: 1280px)")
const tabletQuery = useMediaQuery("(min-width: 768px)") const tabletQuery = useMediaQuery("(min-width: 768px)")
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
const layoutMode = createMemo<LayoutMode>(() => { const layoutMode = createMemo<LayoutMode>(() => {
if (desktopQuery()) return "desktop" if (desktopQuery()) return "desktop"
@@ -127,7 +123,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}) })
const isPhoneLayout = createMemo(() => layoutMode() === "phone") const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -601,7 +596,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base"> <AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]"> <Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show <Show
when={!compactHeaderLayout()} when={!isPhoneLayout()}
fallback={ fallback={
<div class="flex flex-col w-full gap-1.5"> <div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full"> <div class="flex flex-wrap items-center justify-between gap-2 w-full">
@@ -630,7 +625,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
<button <button
type="button" type="button"
class="connection-status-button command-palette-button" class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -639,8 +634,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button> </button>
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<span <span
@@ -651,7 +646,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span> </span>
</div> </div>
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}> <Show when={!props.mobileFullscreenMode}>
<IconButton <IconButton
color="inherit" color="inherit"
onClick={props.onEnterMobileFullscreen} onClick={props.onEnterMobileFullscreen}
@@ -675,18 +670,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{rightAppBarButtonIcon()} {rightAppBarButtonIcon()}
</IconButton> </IconButton>
</Show> </Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1"> <div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<Show when={!showingInfoView()}> <ContextMeter
<ContextMeter usedTokens={tokenStats().used}
usedTokens={tokenStats().used} availableTokens={tokenStats().avail}
availableTokens={tokenStats().avail} formatTokens={formatTokenTotal}
formatTokens={formatTokenTotal} usedLabel={t("instanceShell.metrics.usedLabel")}
usedLabel={t("instanceShell.metrics.usedLabel")} availableLabel={t("instanceShell.metrics.availableLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")} />
/>
</Show>
</div> </div>
</div> </div>
} }
@@ -728,7 +721,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]"> <div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
<button <button
type="button" type="button"
class="connection-status-button command-palette-button" class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -803,14 +796,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<For each={cachedSessionIds()}> <For each={cachedSessionIds()}>
{(sessionId) => { {(sessionId) => {
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId const isActive = () => activeSessionIdForInstance() === sessionId
return ( return (
<div <div
class="session-cache-pane flex flex-col flex-1 min-h-0" class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }} style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId} data-session-id={sessionId}
data-instance-id={props.instance.id}
data-session-active={isActive() ? "true" : "false"}
aria-hidden={!isActive()} aria-hidden={!isActive()}
> >
<SessionView <SessionView
@@ -846,10 +837,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<> <>
<div <div class="instance-shell2 flex flex-col flex-1 min-h-0">
class="instance-shell2 flex flex-col flex-1 min-h-0"
data-instance-id={props.instance.id}
>
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
{sessionLayout} {sessionLayout}
</Show> </Show>

View File

@@ -7,7 +7,7 @@ import {
type Accessor, type Accessor,
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"

View File

@@ -1,13 +1,11 @@
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -34,18 +32,14 @@ interface ChangesTabProps {
} }
const ChangesTab: Component<ChangesTabProps> = (props) => { const ChangesTab: Component<ChangesTabProps> = (props) => {
const sessionId = createMemo(() => props.activeSessionId()) const renderContent = (): JSX.Element => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const sessionId = props.activeSessionId()
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
const sorted = createMemo<any[]>(() => { const hasSession = Boolean(sessionId && sessionId !== "info")
const list = diffs() const diffs = hasSession ? props.activeSessionDiffs() : null
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
})
const totals = createMemo(() => { const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
return sorted().reduce( const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
@@ -53,61 +47,41 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const mostChanged = createMemo<any | null>(() => { const mostChanged = sorted.length
const items = sorted() ? sorted.reduce((best, item) => {
if (items.length === 0) return null const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
return items.reduce((best, item) => { const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 const bestScore = bestAdd + bestDel
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del const score = add + del
if (score > bestScore) return item if (score > bestScore) return item
if (score < bestScore) return best if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, items[0]) }, sorted[0])
}) : null
const selectedFileData = createMemo<any | null>(() => { // Auto-select the most-changed file if none selected.
const currentSelected = props.selectedFile() const currentSelected = props.selectedFile()
const items = sorted() const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
if (currentSelected) {
const match = items.find((f) => f.file === currentSelected) const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
if (match) return match
const emptyViewerMessage = () => {
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
} }
return mostChanged()
})
const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentDiffs = diffs()
if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const headerPath = createMemo(() => {
const file = selectedFileData()
return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
})
const renderContent = (): JSX.Element => {
const sortedList = sorted()
const totalsValue = totals()
const selected = selectedFileData()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selected && hasSession() && sortedList.length > 0 ? selected : null} when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
fallback={ fallback={
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span> <span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
@@ -115,23 +89,15 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<Suspense <MonacoDiffViewer
fallback={ scopeKey={scopeKey}
<div class="file-viewer-empty"> path={String(file().file || "")}
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> before={String((file() as any).before || "")}
</div> after={String((file() as any).after || "")}
} viewMode={props.diffViewMode()}
> contextMode={props.diffContextMode()}
<LazyMonacoDiffViewer wordWrap={props.diffWordWrapMode()}
scopeKey={scopeKey()} />
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>
@@ -143,11 +109,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={sortedList.length > 0} fallback={renderEmptyList()}> <Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout()) props.onSelectFile(item.file, props.isPhoneLayout())
}} }}
@@ -168,11 +134,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={sortedList.length > 0} fallback={renderEmptyList()}> <Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, true) props.onSelectFile(item.file, true)
}} }}
@@ -193,6 +159,8 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
</Show> </Show>
) )
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
@@ -203,10 +171,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totalsValue.additions}</span> <span class="files-tab-stat-value">+{totals.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totalsValue.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
</div> </div>
@@ -230,7 +198,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")} overlayAriaLabel="Changes"
/> />
) )
} }

View File

@@ -1,13 +1,11 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client" import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel" import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
const LazyMonacoFileViewer = lazy(() => import SplitFilePanel from "../components/SplitFilePanel"
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
interface FilesTabProps { interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => { const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading") if (props.browserLoading() && entriesValue === null) return "Loading files..."
return props.t("instanceShell.filesShell.viewerEmpty") return "Select a file to preview"
} }
const renderViewer = () => ( const renderViewer = () => (
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
{(payload) => ( {(payload) => (
<Suspense <MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
</Suspense>
)} )}
</Show> </Show>
} }
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> <span class="file-viewer-empty-text">Loading</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show> </Show>
<Show when={props.browserLoading() && entriesValue === null}> <Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div> <div class="p-3 text-xs text-secondary">Loading files...</div>
</Show> </Show>
<For each={sorted}> <For each={sorted}>
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</span> </span>
</span> </span>
<Show when={props.browserLoading()}> <Show when={props.browserLoading()}>
<span>{props.t("instanceInfo.loading")}</span> <span>Loading</span>
</Show> </Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")} overlayAriaLabel="Files"
/> />
) )
} }

View File

@@ -1,16 +1,14 @@
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -48,18 +46,17 @@ interface GitChangesTabProps {
} }
const GitChangesTab: Component<GitChangesTabProps> = (props) => { const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const sessionId = createMemo(() => props.activeSessionId()) const renderContent = (): JSX.Element => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const sessionId = props.activeSessionId()
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const sorted = createMemo<GitFileStatus[]>(() => { const hasSession = Boolean(sessionId && sessionId !== "info")
const list = entries() const entries = hasSession ? props.entries() : null
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const totals = createMemo(() => { const sorted = Array.isArray(entries)
return sorted().reduce( ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
: []
const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0 acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0
@@ -67,33 +64,21 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
const emptyViewerMessage = () => {
if (!hasSession) return "Select a session to view changes."
if (entries === null) return "Loading git changes…"
if (nonDeleted.length === 0) return "No git changes yet."
return "No file selected."
}
const selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath() const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath() const fallbackPath = props.mostChangedPath()
const found = const selectedEntry =
list.find((item) => item.path === selectedPath) || sorted.find((item) => item.path === selectedPath) ||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
return found ?? null
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const renderContent = (): JSX.Element => {
const totalsValue = totals()
const selected = selectedEntry()
const sortedList = sorted()
const nonDeletedList = nonDeleted()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
@@ -106,12 +91,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
fallback={ fallback={
<Show <Show
when={ when={
selected && selectedEntry &&
props.selectedBefore() !== null && props.selectedBefore() !== null &&
props.selectedAfter() !== null && props.selectedAfter() !== null &&
selected.status !== "deleted" selectedEntry.status !== "deleted"
? { ? {
path: selected.path, path: selectedEntry.path,
before: props.selectedBefore() as string, before: props.selectedBefore() as string,
after: props.selectedAfter() as string, after: props.selectedAfter() as string,
} }
@@ -124,23 +109,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<Suspense <MonacoDiffViewer
fallback={ scopeKey={props.scopeKey()}
<div class="file-viewer-empty"> path={String(file().path || "")}
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> before={String((file() as any).before || "")}
</div> after={String((file() as any).after || "")}
} viewMode={props.diffViewMode()}
> contextMode={props.diffContextMode()}
<LazyMonacoDiffViewer wordWrap={props.diffWordWrapMode()}
scopeKey={props.scopeKey()} />
path={String(file().path || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
} }
@@ -154,7 +131,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> <span class="file-viewer-empty-text">Loading</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -164,8 +141,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div> const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> <Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -179,7 +156,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> <span class="text-[10px] text-secondary">deleted</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -196,8 +173,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> <Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <For each={sorted}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -210,7 +187,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> <span class="text-[10px] text-secondary">deleted</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -227,19 +204,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}> <span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span> <span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totalsValue.additions}</span> <span class="files-tab-stat-value">+{totals.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totalsValue.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -249,23 +226,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
class="files-header-icon-button" class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={!hasSession() || props.statusLoading() || entries() === null} disabled={!hasSession || props.statusLoading() || entries === null}
style={{ "margin-left": "auto" }} style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </button>
<DiffToolbar <DiffToolbar
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()} wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange} onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange} onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange} onWordWrapModeChange={props.onWordWrapModeChange}
/> />
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}
@@ -274,7 +251,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")} overlayAriaLabel="Git Changes"
/> />
) )
} }

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
type MarkdownModule = typeof import("../lib/markdown")
let markdownModulePromise: Promise<MarkdownModule> | null = null
function loadMarkdownModule(): Promise<MarkdownModule> {
if (!markdownModulePromise) {
markdownModulePromise = import("../lib/markdown").catch((error) => {
markdownModulePromise = null
throw error
})
}
return markdownModulePromise
}
function hashText(value: string): string { function hashText(value: string): string {
let hash = 2166136261 let hash = 2166136261
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
return `text-${hashText(text)}` return `text-${hashText(text)}`
} }
function resolvePartCacheId(part: TextPart, text: string): string {
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (partId) {
return partId
}
return `anonymous:${hashText(text)}`
}
function decodeHtmlEntitiesLocally(content: string): string {
if (!content.includes("&") || typeof document === "undefined") {
return content
}
const textarea = document.createElement("textarea")
textarea.innerHTML = content
return textarea.value
}
function escapeHtml(content: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
}
function renderFallbackHtml(content: string): string {
if (!content) {
return ""
}
return escapeHtml(content).replace(/\n/g, "<br />")
}
interface MarkdownProps { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string instanceId?: string
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
const { t } = useI18n() const { t } = useI18n()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let latestRequestKey = "" let latestRequestedText = ""
let cleanupLanguageListener: (() => void) | undefined
const notifyRendered = () => { const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntities(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
const cacheId = resolvePartCacheId(part, text) if (!partId) {
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` return { part, text, themeKey, highlightEnabled, partId, version }
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { partId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => { createEffect(async () => {
const cacheEntry: RenderCache = { const { part, text, themeKey, highlightEnabled, version } = resolved()
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => { // Ensure the markdown highlighter theme matches the active UI theme.
const markdown = await loadMarkdownModule() setMarkdownTheme(themeKey === "dark")
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
})
if (latestRequestKey === snapshot.requestKey) { latestRequestedText = text
commitCacheEntry(snapshot, rendered)
}
}
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version return cache.theme === themeKey && cache.mode === version
} }
const localCache = snapshot.part.renderCache const localCache = part.renderCache
if (localCache && cacheMatches(localCache)) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
@@ -164,91 +92,115 @@ export function Markdown(props: MarkdownProps) {
const globalCache = cacheHandle.get<RenderCache>() const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && cacheMatches(globalCache)) { if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html) setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered() notifyRendered()
return return
} }
setHtml(renderFallbackHtml(snapshot.text)) const commitCacheEntry = (renderedHtml: string) => {
notifyRendered() const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
void renderSnapshot(snapshot).catch((error) => { if (!highlightEnabled) {
log.error("Failed to render markdown:", error) part.renderCache = undefined
if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text)) try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
} }
}) return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
}
}) })
onMount(() => { onMount(() => {
const handleClick = async (event: Event) => { const handleClick = async (e: Event) => {
const target = event.target as HTMLElement const target = e.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
if (!copyButton) { if (copyButton) {
return e.preventDefault()
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
if (success) {
copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} else {
copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
}
}
} }
event.preventDefault()
const code = copyButton.getAttribute("data-code")
if (!code) {
return
}
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (!copyText) {
return
}
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} }
containerRef?.addEventListener("click", handleClick) containerRef?.addEventListener("click", handleClick)
let disposed = false const cleanupLanguageListener = onLanguagesLoaded(async () => {
void loadMarkdownModule() if (props.disableHighlight) {
.then((markdown) => { return
if (disposed) { }
return
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
} }
} catch (error) {
cleanupLanguageListener = markdown.onLanguagesLoaded(() => { log.error("Failed to re-render markdown after language load:", error)
const snapshot = resolved() }
if (!snapshot.highlightEnabled) { })
return
}
latestRequestKey = snapshot.requestKey
void renderSnapshot(snapshot).catch((error) => {
log.error("Failed to re-render markdown after language load:", error)
})
})
})
.catch((error) => {
log.error("Failed to load markdown module:", error)
})
onCleanup(() => { onCleanup(() => {
disposed = true
containerRef?.removeEventListener("click", handleClick) containerRef?.removeEventListener("click", handleClick)
cleanupLanguageListener?.() cleanupLanguageListener()
cleanupLanguageListener = undefined
}) })
}) })
return ( const proseClass = () => "markdown-body"
<div
ref={containerRef} return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
class="markdown-body"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
)
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -12,17 +12,8 @@ import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -32,10 +23,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk/v2").ToolState type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running") return Boolean(state && state.status === "running")
@@ -203,13 +194,8 @@ interface MessageContentItemProps {
messageIndex: number messageIndex: number
lastAssistantIndex: () => number lastAssistantIndex: () => number
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function isSupportedPartType(part: unknown): boolean { function isSupportedPartType(part: unknown): boolean {
@@ -296,12 +282,7 @@ function MessageContentItem(props: MessageContentItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
showAgentMeta={showAgentMeta()} showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -317,41 +298,11 @@ interface ToolCallItemProps {
messageId: string messageId: string
partId: string partId: string
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`))
const isDeleteOverlayActive = () => {
if (isSelectedForDeletion()) return true
if (isSelectedToolPartForDeletion()) return true
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -368,6 +319,14 @@ function ToolCallItem(props: ToolCallItemProps) {
const messageVersion = createMemo(() => record()?.revision ?? 0) const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0) const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const deleteDisabled = createMemo(() => {
if (deleting()) return true
// Avoid deleting while a tool is actively running to prevent confusing UI states.
if (isToolStateRunning(toolState())) return true
// Avoid deleting permission prompts from here; those are interactive.
return Boolean(toolPart()?.pendingPermission)
})
const taskSessionId = createMemo(() => { const taskSessionId = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return "" if (!state) return ""
@@ -391,72 +350,38 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location) navigateToTaskSession(location)
} }
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteToolPart = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (deleteDisabled()) return
if (deletingMessage()) return
setDeletingMessage(true) setDeleting(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messageBlock.tool.deletePart.failed.title"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<Show when={toolPart()}> <Show when={toolPart()}>
{(resolvedToolPart) => ( {(resolvedToolPart) => (
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}> <>
<div class="tool-call-header-label"> <div class="tool-call-header-label">
<div class="tool-call-header-meta"> <div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="tool-call-icon">{TOOL_ICON}</span> <span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span> <span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div> </div>
<div class="flex items-center gap-0"> <div class="flex items-center gap-2">
<Show when={taskSessionId()}> <Show when={taskSessionId()}>
<button <button
class="tool-call-header-button" class="tool-call-header-button"
@@ -470,33 +395,16 @@ function ToolCallItem(props: ToolCallItemProps) {
</button> </button>
</Show> </Show>
<Show when={props.showDeleteMessage}> <button
<button class="tool-call-header-button"
class="tool-call-header-button" type="button"
type="button" disabled={deleteDisabled()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} onClick={handleDeleteToolPart}
onClick={handleDeleteUpTo} title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} >
title={t("messageItem.actions.deleteMessagesUpTo")} <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
aria-label={t("messageItem.actions.deleteMessagesUpTo")} </button>
>
<DeleteUpToIcon />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</div> </div>
@@ -510,7 +418,7 @@ function ToolCallItem(props: ToolCallItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </>
)} )}
</Show> </Show>
) )
@@ -562,13 +470,7 @@ interface MessageBlockProps {
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
} }
@@ -578,29 +480,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
const selected = props.selectedMessageIds?.() ?? new Set<string>()
if (selected.has(props.messageId)) {
return true
}
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
const block = createMemo<MessageDisplayBlock | null>(() => { const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record() const current = record()
@@ -789,13 +668,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return ( return (
<Show when={block()}> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div <div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}> <For each={resolvedBlock().items}>
{(item, index) => ( {(item) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item.type === "content"}>
<MessageContentItem <MessageContentItem
@@ -806,12 +681,7 @@ export default function MessageBlock(props: MessageBlockProps) {
startPartId={(item as ContentDisplayItem).startPartId} startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -827,13 +697,6 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </div>
@@ -846,14 +709,6 @@ export default function MessageBlock(props: MessageBlockProps) {
part={(item as StepDisplayItem).part} part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta showAgentMeta
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
@@ -863,14 +718,6 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()} showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor} borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item.type === "compaction"}>
@@ -881,11 +728,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0} partId={(item as CompactionDisplayItem).partId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
@@ -895,13 +738,9 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -920,14 +759,6 @@ interface StepCardProps {
showAgentMeta?: boolean showAgentMeta?: boolean
showUsage?: boolean showUsage?: boolean
borderColor?: string borderColor?: string
showDeleteMessage?: boolean
instanceId?: string
sessionId?: string
messageId?: string
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
interface CompactionCardProps { interface CompactionCardProps {
@@ -937,18 +768,12 @@ interface CompactionCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
showDeleteMessage?: boolean partId: string
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function CompactionCard(props: CompactionCardProps) { function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -956,98 +781,44 @@ function CompactionCard(props: CompactionCardProps) {
const containerClass = () => const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDelete = () => Boolean(props.partId) && !deleting()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDelete = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (!canDelete()) return
if (!canDeleteMessage()) return setDeleting(true)
setDeletingMessage(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div <div
class={`delete-hover-scope ${containerClass()} relative`} class={`${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label={t("messageBlock.compaction.ariaLabel")} aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1"> <button
<Show when={props.showDeleteMessage}> type="button"
<button class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
type="button" disabled={!canDelete()}
class="tool-call-header-button" onClick={handleDelete}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} title={t("messagePart.actions.deleteTitle")}
onClick={handleDeleteUpTo} >
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} {deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} </button>
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="message-compaction-row"> <div class="message-compaction-row">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span> <span class="message-compaction-label">{label()}</span>
</div> </div>
@@ -1057,9 +828,6 @@ function CompactionCard(props: CompactionCardProps) {
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
const timestamp = () => { const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value) const date = new Date(value)
@@ -1104,42 +872,6 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const canDeleteMessage = () =>
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.messageId) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
}
}
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => { const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
@@ -1170,83 +902,17 @@ function StepCard(props: StepCardProps) {
return null return null
} }
return ( return (
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}> <div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showDeleteMessage}>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
{renderUsageChips(usage)} {renderUsageChips(usage)}
</div> </div>
) )
} }
return ( return (
<div class={`message-step-card message-step-start relative`}> <div class={`message-step-card message-step-start`}>
<div class="message-step-heading"> <div class="message-step-heading">
<div class="message-step-title"> <div class="message-step-title">
<div class="message-step-title-left"> <div class="message-step-title-left">
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show> <Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
@@ -1273,21 +939,15 @@ interface ReasoningCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded?: boolean defaultExpanded?: boolean
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deleting, setDeleting] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1314,8 +974,6 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID return modelID
} }
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const reasoningText = () => { const reasoningText = () => {
const part = props.part as any const part = props.part as any
if (!part) return "" if (!part) return ""
@@ -1356,44 +1014,29 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const hasDeleteTarget = () => Boolean(props.partId)
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDelete = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!props.showDeleteMessage) return if (!canDelete()) return
if (!canDeleteMessage()) return setDeleting(true)
setDeletingMessage(true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.messageId) await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setDeleting(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div class="delete-hover-scope message-reasoning-card"> <div class="message-reasoning-card">
<div class="message-reasoning-header"> <div class="message-reasoning-header">
<button <button
type="button" type="button"
@@ -1402,28 +1045,22 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-expanded={expanded()} aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label"> <span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span class="message-reasoning-label-primary"> <span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showDeleteMessage}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<input <span class="message-step-meta-inline">
class="message-select-checkbox" <Show when={agentIdentifier()}>
type="checkbox" {(value) => (
checked={isSelectedForDeletion()} <span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
onClick={(event) => { )}
event.stopPropagation() </Show>
}} <Show when={modelIdentifier()}>
onChange={(event) => { {(value) => (
event.stopPropagation() <span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
const next = Boolean((event.currentTarget as HTMLInputElement).checked) )}
props.onToggleSelectedMessage?.(props.messageId, next) </Show>
}} </span>
aria-label={t("messageItem.selection.checkboxAriaLabel")} </Show>
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span>
</span> </span>
</button> </button>
@@ -1444,31 +1081,16 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</button> </button>
<Show when={props.showDeleteMessage}> <Show when={hasDeleteTarget()}>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
onClick={handleDeleteUpTo} onClick={handleDelete}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} disabled={!canDelete()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })} aria-label={t("messagePart.actions.deleteTitle")}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={t("messagePart.actions.deleteTitle")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
> >
<DeleteUpToIcon /> <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
@@ -1476,23 +1098,6 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</div> </div>
<Show when={hasMeta()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">

View File

@@ -1,24 +1,14 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { For, Show, createSignal } from "solid-js"
import { Portal } from "solid-js/web" import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart } from "../types/message"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -28,112 +18,15 @@ interface MessageItemProps {
isQueued?: boolean isQueued?: boolean
parts: ClientPart[] parts: ClientPart[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
type ImagePreviewState = {
url: string
name: string
anchor: HTMLElement
}
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const getImagePreviewPosition = () => {
const state = imagePreview()
if (!state) return null
const rect = state.anchor.getBoundingClientRect()
// Outer box: 320px image + 8px padding on each side.
const padding = 8
const maxImage = 320
const gap = 8
const chrome = padding * 2
const outerWidth = maxImage + chrome
const outerHeight = maxImage + chrome
const viewportW = window.innerWidth
const viewportH = window.innerHeight
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
const fitsAbove = rect.top >= outerHeight + gap + 8
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
return { left, top }
}
createEffect(() => {
const active = imagePreview()
if (!active) return
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
// Hide the popover to avoid showing it in the wrong place.
const hide = () => setImagePreview(null)
window.addEventListener("scroll", hide, true)
window.addEventListener("resize", hide)
onCleanup(() => {
window.removeEventListener("scroll", hide, true)
window.removeEventListener("resize", hide)
})
})
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
let topRowEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let speakerPrimaryEl: HTMLDivElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
const metaText = () => agentMeta()
const updateMetaLayout = () => {
const text = metaText()
if (!text) return
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
const rowWidth = topRowEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
// Allow for the flex gap between left and actions.
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
const text = metaText()
if (!text || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (topRowEl) observer.observe(topRowEl)
if (actionsEl) observer.observe(actionsEl)
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
onCleanup(() => observer.disconnect())
})
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -230,11 +123,6 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
if (!url) return
setImagePreview({ anchor, url, name })
}
const errorMessage = () => { const errorMessage = () => {
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.error) return null if (!info || info.role !== "assistant" || !info.error) return null
@@ -302,30 +190,47 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const handleDeleteMessage = async () => { const deletableTextPartId = () => {
if (deletingMessage()) return const part = props.parts.find((candidate) => {
setDeletingMessage(true) if (!candidate || candidate.type !== "text") return false
const id = (candidate as any).id
if (typeof id !== "string" || id.length === 0) return false
return !Boolean((candidate as any).synthetic)
})
return (part as any)?.id as string | undefined
}
const isDeletingPart = (partId?: string) => {
if (!partId) return false
return deletingParts().has(partId)
}
const setPartDeleting = (partId: string, value: boolean) => {
setDeletingParts((prev) => {
const next = new Set(prev)
if (value) {
next.add(partId)
} else {
next.delete(partId)
}
return next
})
}
const handleDeletePart = async (partId?: string) => {
if (!partId) return
if (isDeletingPart(partId)) return
setPartDeleting(partId, true)
try { try {
await deleteMessage(props.instanceId, props.sessionId, props.record.id) await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), { showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"), title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeletingMessage(false) setPartDeleting(partId, false)
}
}
const handleDeleteUpTo = async () => {
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.record.id)
} finally {
setDeletingUpTo(false)
} }
} }
@@ -353,16 +258,8 @@ export default function MessageItem(props: MessageItemProps) {
if (!info || info.role !== "assistant") return "" if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || "" const modelID = info.modelID || ""
const providerID = info.providerID || "" const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID return modelID
if (!base) return ""
const variant = (info as SDKAssistantMessageV2).variant
if (typeof variant === "string" && variant.trim().length > 0) {
return `${base} (${variant.trim()})`
}
return base
} }
const agentMeta = () => { const agentMeta = () => {
@@ -381,68 +278,28 @@ export default function MessageItem(props: MessageItemProps) {
return ( return (
<div <div class={containerClass()}>
class={containerClass()}
data-view="message-item"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}> <header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}> <div class="message-item-header-row message-item-header-row--top">
<div class="message-header-left"> <div class="message-speaker">
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}> <span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
<Show when={props.showDeleteMessage}> {speakerLabel()}
<input </span>
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.record.id, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<Show when={metaText() && showMetaInline()}>
<span class="message-agent-meta-inline">{metaText()}</span>
</Show>
<Show when={metaText()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-agent-meta-inline message-agent-meta-inline--measure"
>
{metaText()}
</span>
</Show>
</div> </div>
<div class="message-item-actions" ref={(el) => (actionsEl = el)}> <div class="message-item-actions">
<Show when={isUser()}> <Show when={isUser()}>
<div class="message-action-group"> <div class="message-action-group">
<button <Show when={props.onRevert}>
class="message-action-button" <button
onClick={handleCopy} class="message-action-button"
title={copyLabel()} onClick={handleRevert}
aria-label={copyLabel()} title={t("messageItem.actions.revert")}
> aria-label={t("messageItem.actions.revert")}
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> >
</button> <Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
@@ -453,43 +310,14 @@ export default function MessageItem(props: MessageItemProps) {
<Split class="w-3.5 h-3.5" aria-hidden="true" /> <Split class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<button
<Show when={props.onRevert}> class="message-action-button"
<button onClick={handleCopy}
class="message-action-button" title={copyLabel()}
onClick={handleRevert} aria-label={copyLabel()}
title={t("messageItem.actions.revertTitle")} >
aria-label={t("messageItem.actions.revertTitle")} <Copy class="w-3.5 h-3.5" aria-hidden="true" />
> </button>
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</Show> </Show>
<Show when={!isUser()}> <Show when={!isUser()}>
@@ -503,30 +331,18 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={props.showDeleteMessage}> <Show when={deletableTextPartId()}>
<button {(partId) => (
class="message-action-button" <button
onClick={() => void handleDeleteUpTo()} class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()} onClick={() => void handleDeletePart(partId())}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })} disabled={isDeletingPart(partId())}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })} title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
title={t("messageItem.actions.deleteMessagesUpTo")} aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")} >
> <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
<DeleteUpToIcon /> </button>
</button> )}
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show> </Show>
</div> </div>
</Show> </Show>
@@ -534,10 +350,12 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</div> </div>
<Show when={metaText() && !showMetaInline()}> <Show when={agentMeta()}>
<div class="message-item-header-row message-item-header-row--meta"> {(meta) => (
<span class="message-agent-meta-block">{metaText()}</span> <div class="message-item-header-row message-item-header-row--bottom">
</div> <span class="message-agent-meta">{meta()}</span>
</div>
)}
</Show> </Show>
</header> </header>
@@ -560,20 +378,16 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<For each={messageParts()}> <For each={messageParts()}>
{(part) => { {(part) => (
return ( <MessagePart
<div class="message-part-shell"> part={part}
<MessagePart messageType={props.record.role}
part={part} instanceId={props.instanceId}
messageType={props.record.role} sessionId={props.sessionId}
instanceId={props.instanceId} primaryUserTextPartId={primaryUserTextPartId()}
sessionId={props.sessionId} onRendered={props.onContentRendered}
primaryUserTextPartId={primaryUserTextPartId()} />
onRendered={props.onContentRendered} )}
/>
</div>
)
}}
</For> </For>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
@@ -583,16 +397,7 @@ export default function MessageItem(props: MessageItemProps) {
const name = getAttachmentName(attachment) const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment) const isImage = isImageAttachment(attachment)
return ( return (
<div <div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={name}
onMouseEnter={(e) => {
if (!isImage) return
const el = e.currentTarget as HTMLElement
showImagePreview(el, attachment.url || "", name)
}}
onMouseLeave={() => setImagePreview(null)}
>
<Show when={isImage} fallback={ <Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -620,6 +425,24 @@ export default function MessageItem(props: MessageItemProps) {
</svg> </svg>
</button> </button>
</Show> </Show>
<button
type="button"
onClick={() => void handleDeletePart(attachment.id)}
class="attachment-remove"
disabled={isDeletingPart(attachment.id)}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div> </div>
) )
}} }}
@@ -627,31 +450,6 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")} <span class="generating-spinner"></span> {t("messageItem.status.sending")}

View File

@@ -131,12 +131,7 @@ export default function MessagePart(props: MessagePartProps) {
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div <div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
data-role={textContainerRole()}
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,7 +4,6 @@ import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions" import { agents, executeCustomCommand } from "../stores/sessions"
@@ -14,41 +13,12 @@ import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences" import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState" import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
const log = getLogger("actions") const log = getLogger("actions")
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
const usedCounters = new Set<string>()
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
const counter = match?.[1]
if (counter) usedCounters.add(counter)
}
if (usedCounters.size === 0) return []
const consumed = new Set<string>()
for (const attachment of attachments) {
if (!attachment?.id) continue
if (attachment?.source?.type !== "text") continue
const display = attachment.display
if (typeof display !== "string") continue
const match = display.match(pastedDisplayCounterRegex)
if (!match?.[1]) continue
if (usedCounters.has(match[1])) {
consumed.add(attachment.id)
}
}
return Array.from(consumed)
}
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n() const { t } = useI18n()
const [, setIsFocused] = createSignal(false) const [, setIsFocused] = createSignal(false)
@@ -276,12 +246,7 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 && commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName) getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : "" const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt const historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry) const refreshHistory = () => recordHistoryEntry(historyEntry)
@@ -297,10 +262,6 @@ export default function PromptInput(props: PromptInputProps) {
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -320,7 +281,7 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else if (isKnownSlashCommand) { } else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
} else { } else {
await props.onSend(resolvedPrompt, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
@@ -390,9 +351,7 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n") const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return if (!blockquote) return
// End the blockquote with a blank line so the user's next line insertBlockContent(`${blockquote}\n`)
// doesn't get parsed as a lazy continuation of the quote.
insertBlockContent(`${blockquote}\n\n`)
} }
function insertCodeSelection(rawText: string) { function insertCodeSelection(rawText: string) {

View File

@@ -10,7 +10,6 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
@@ -56,22 +55,12 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
let promptInputApi: PromptInputApi | null = null let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let scrollToBottomHandle: (() => void) | undefined let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined let rootRef: HTMLDivElement | undefined
function shouldScrollToBottomOnActivate() {
const current = session()
if (!current) return true
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
return !snapshot || snapshot.atBottom
}
function scheduleScrollToBottom() { function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return if (!scrollToBottomHandle) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -80,7 +69,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
if (!props.isActive) return if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom() scheduleScrollToBottom()
}) })
@@ -237,35 +225,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} }
async function handleDeleteMessagesUpTo(messageId: string) {
const ids = messageStore().getSessionMessageIds(props.sessionId)
const index = ids.indexOf(messageId)
if (index === -1) return
const restoredText = getUserMessageText(messageId)
const toDelete = ids.slice(index)
try {
for (let idx = toDelete.length - 1; idx >= 0; idx -= 1) {
await deleteMessage(props.instanceId, props.sessionId, toDelete[idx])
}
} catch (error) {
log.error("Failed to delete messages up to", error)
showAlertDialog(t("sessionView.alerts.deleteUpToFailed.message"), {
title: t("sessionView.alerts.deleteUpToFailed.title"),
variant: "error",
})
} finally {
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
}
}
async function handleFork(messageId?: string) { async function handleFork(messageId?: string) {
if (!messageId) { if (!messageId) {
log.warn("Fork requires a user message id") log.warn("Fork requires a user message id")
@@ -324,17 +283,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
<MessageSection <MessageSection
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={activeSession.id} sessionId={activeSession.id}
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onFork={handleFork}
onFork={handleFork} isActive={props.isActive}
isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { if (props.isActive) {
if (shouldScrollToBottomOnActivate()) { scheduleScrollToBottom()
scheduleScrollToBottom()
}
} }
}} }}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -1,7 +1,7 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils" import { escapeHtml } from "../../lib/markdown"
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types" import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
type AnsiRenderCache = RenderCache & { hasAnsi: boolean } type AnsiRenderCache = RenderCache & { hasAnsi: boolean }

View File

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

View File

@@ -1,26 +1,11 @@
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils" import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache" import { getCacheEntry } from "../../lib/global-cache"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
)
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
onMount(() => {
props.onRendered?.()
})
return (
<div class="tool-call-diff-viewer">
<div innerHTML={props.html} />
</div>
)
}
type CacheHandle = { type CacheHandle = {
get<T>(): T | undefined get<T>(): T | undefined
params(): unknown params(): unknown
@@ -116,20 +101,15 @@ export function createDiffContentRenderer(params: {
</button> </button>
</div> </div>
</div> </div>
{cachedHtml ? ( <ToolCallDiffViewer
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} /> diffText={payload.diffText}
) : ( filePath={payload.filePath}
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}> theme={themeKey}
<LazyToolCallDiffViewer mode={diffMode()}
diffText={payload.diffText} cachedHtml={cachedHtml}
filePath={payload.filePath} cacheEntryParams={cacheEntryParams as any}
theme={themeKey} onRendered={handleDiffRendered}
mode={diffMode()} />
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,398 +0,0 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
export interface VirtualFollowListApi {
scrollToTop: (opts?: { immediate?: boolean }) => void
scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean }) => void
scrollToKey: (
key: string,
opts?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition; setAutoScroll?: boolean },
) => void
notifyContentRendered: () => void
setAutoScroll: (enabled: boolean) => void
getAutoScroll: () => boolean
getScrollElement: () => HTMLDivElement | undefined
getShellElement: () => HTMLDivElement | undefined
}
export interface VirtualFollowListState {
autoScroll: Accessor<boolean>
showScrollTopButton: Accessor<boolean>
showScrollBottomButton: Accessor<boolean>
scrollButtonsCount: Accessor<number>
activeKey: Accessor<string | null>
}
export interface VirtualFollowListProps<T> {
items: Accessor<T[]>
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => JSX.Element
/**
* Optional stable DOM id for the item wrapper.
* Defaults to the key itself.
*/
getAnchorId?: (key: string) => string
/**
* Decode an item key from an observed wrapper element id.
* Defaults to identity.
*/
getKeyFromAnchorId?: (anchorId: string) => string
overscanPx?: number
scrollSentinelMarginPx?: number
virtualizationEnabled?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
loading?: Accessor<boolean>
isActive?: Accessor<boolean>
/**
* When switching back to an inactive (cached) pane, the list historically
* re-pinned to the bottom if autoScroll was enabled.
*
* Disable this to preserve the existing scroll position across pane switches.
*/
scrollToBottomOnActivate?: Accessor<boolean>
/**
* Controls whether the list should scroll to bottom the first time items
* appear (default behavior for chat streams).
*
* Set to false when an outer component restores scroll from a cache.
*/
initialScrollToBottom?: Accessor<boolean>
/**
* Initial value for the internal autoScroll signal.
* Useful when restoring scroll state (e.g. start in non-follow mode).
*/
initialAutoScroll?: Accessor<boolean>
/**
* When this value changes, the list resets internal follow/anchor state.
* Useful when reusing the same list instance across different datasets.
*/
resetKey?: Accessor<string | number>
/**
* If this value changes and autoScroll is enabled, the list will
* anchor-scroll to the bottom (unless suppressed).
*/
followToken?: Accessor<string | number>
/**
* Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list.
*/
renderBeforeItems?: Accessor<JSX.Element>
/**
* Render content inside the shell, above timeline/sidebar layers.
* (Quote popovers, etc.)
*/
renderOverlay?: Accessor<JSX.Element>
/**
* Provide localized labels for built-in controls.
*/
scrollToTopAriaLabel?: Accessor<string>
scrollToBottomAriaLabel?: Accessor<string>
/**
* Receive element refs for external logic (selection, geometry, etc.)
*/
onScrollElementChange?: (element: HTMLDivElement | undefined) => void
onShellElementChange?: (element: HTMLDivElement | undefined) => void
/**
* Callbacks for consumers.
*/
onScroll?: () => void
onMouseUp?: (event: MouseEvent) => void
onClick?: (event: MouseEvent) => void
onActiveKeyChange?: (key: string | null) => void
registerApi?: (api: VirtualFollowListApi) => void
registerState?: (state: VirtualFollowListState) => void
renderControls?: (state: VirtualFollowListState, api: VirtualFollowListApi) => JSX.Element
}
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
const isActive = () => (props.isActive ? props.isActive() : true)
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
let suppressAutoScrollOnce = false
let pendingInitialScroll = true
const state: VirtualFollowListState = {
autoScroll,
showScrollTopButton,
showScrollBottomButton,
scrollButtonsCount,
activeKey,
}
function markUserScrollIntent(direction?: "up" | "down" | null) {
const now = performance.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
if (direction) {
lastUserScrollIntentDirection = direction
}
}
function hasUserScrollIntent() {
return performance.now() <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
if (!element) return
const handleWheelIntent = (event: WheelEvent) => {
const dir: "up" | "down" | null = event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null
markUserScrollIntent(dir)
}
const handlePointerIntent = () => markUserScrollIntent(null)
const handleKeyIntent = (event: KeyboardEvent) => {
if (!SCROLL_INTENT_KEYS.has(event.key)) return
const key = event.key
const dir: "up" | "down" | null =
key === "ArrowUp" || key === "PageUp" || key === "Home"
? "up"
: key === "ArrowDown" || key === "PageDown" || key === "End"
? "down"
: key === " " || key === "Spacebar"
? event.shiftKey
? "up"
: "down"
: null
markUserScrollIntent(dir)
}
element.addEventListener("wheel", handleWheelIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handleWheelIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function updateScrollButtons() {
const handle = virtuaHandle()
const element = scrollElement()
if (!handle || !element) return
const offset = handle.scrollOffset
const scrollHeight = handle.scrollSize
const clientHeight = element.clientHeight
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const hasItems = props.items().length > 0
setShowScrollBottomButton(hasItems && !atBottom)
setShowScrollTopButton(hasItems && !atTop)
// Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) {
if (atBottom && !autoScroll()) {
setAutoScroll(true)
} else if (!atBottom && autoScroll()) {
setAutoScroll(false)
}
}
}
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
const handle = virtuaHandle()
if (!handle) return
if (options?.suppressAutoAnchor ?? !immediate) {
suppressAutoScrollOnce = true
}
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
setAutoScroll(true)
}
function scrollToTop(immediate = true) {
const handle = virtuaHandle()
if (!handle) return
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
setAutoScroll(false)
}
function handleScroll() {
const isUserScroll = hasUserScrollIntent()
if (isUserScroll) {
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
setAutoScroll(false)
}
}
updateScrollButtons()
props.onScroll?.()
// Find active key (roughly the first visible item)
const handle = virtuaHandle()
if (handle) {
const start = handle.findItemIndex(handle.scrollOffset)
const items = props.items()
if (items[start]) {
const key = props.getKey(items[start], start)
if (key !== activeKey()) {
setActiveKey(key)
props.onActiveKeyChange?.(key)
}
}
}
}
const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToKey: (key, opts) => {
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
if (index === -1) return
const nextAutoScroll = opts?.setAutoScroll ?? false
setAutoScroll(nextAutoScroll)
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
},
notifyContentRendered: () => {
if (autoScroll()) {
scrollToBottom(true)
}
},
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
getAutoScroll: () => autoScroll(),
getScrollElement: () => scrollElement(),
getShellElement: () => shellElement(),
}
createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state))
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
}
suppressAutoScrollOnce = false
}, { defer: true }))
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) {
scrollToBottom(true)
}
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return
lastResetKey = nextKey
setAutoScroll(initialAutoScroll())
pendingInitialScroll = true
}))
// Initial scroll and session activation
createEffect(() => {
const active = isActive()
if (!active) return
if (pendingInitialScroll && props.items().length > 0) {
pendingInitialScroll = false
if (initialScrollToBottom()) {
scrollToBottom(true)
}
} else if (autoScroll() && scrollToBottomOnActivate()) {
scrollToBottom(true)
}
})
return (
<div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement)
props.onShellElementChange?.(shellElement)
}}>
<div
class="message-stream"
ref={el => {
setScrollElement(el)
props.onScrollElementChange?.(el)
attachScrollIntentListeners(el)
}}
onMouseUp={props.onMouseUp}
onClick={props.onClick}
>
<Show when={props.renderBeforeItems}>
{props.renderBeforeItems!()}
</Show>
<Virtualizer
ref={setVirtuaHandle}
scrollRef={scrollElement()}
data={props.items()}
bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll}
>
{(item, index) => props.renderItem(item, index())}
</Virtualizer>
</div>
<Show when={props.renderOverlay}>
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
</Show>
<Show when={props.renderControls}>
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
</Show>
<Show
when={
!props.renderControls &&
(showScrollTopButton() || showScrollBottomButton()) &&
props.scrollToTopAriaLabel &&
props.scrollToBottomAriaLabel
}
>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,343 @@
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 32
const VISIBILITY_BUFFER_PX = 48
type ObserverRoot = Element | Document | null
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
interface SharedObserver {
observer: IntersectionObserver
listeners: Map<Element, Set<IntersectionCallback>>
}
const NULL_ROOT_KEY = "__null__"
const rootIds = new WeakMap<Element | Document, number>()
let sharedRootId = 0
const sharedObservers = new Map<string, SharedObserver>()
function getRootKey(root: ObserverRoot, margin: number): string {
if (!root) {
return `${NULL_ROOT_KEY}:${margin}`
}
let id = rootIds.get(root)
if (id === undefined) {
id = ++sharedRootId
rootIds.set(root, id)
}
return `${id}:${margin}`
}
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
const listeners = new Map<Element, Set<IntersectionCallback>>()
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const callbacks = listeners.get(entry.target as Element)
if (!callbacks) return
callbacks.forEach((fn) => fn(entry))
})
},
{
root: root ?? undefined,
rootMargin: `${margin}px 0px ${margin}px 0px`,
},
)
return { observer, listeners }
}
function shouldRenderEntry(entry: IntersectionObserverEntry) {
const rootBounds = entry.rootBounds
if (!rootBounds) {
return entry.isIntersecting
}
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
return false
}
return true
}
function subscribeToSharedObserver(
target: Element,
root: ObserverRoot,
margin: number,
callback: IntersectionCallback,
): () => void {
if (typeof IntersectionObserver === "undefined") {
callback({ isIntersecting: true } as IntersectionObserverEntry)
return () => {}
}
const key = getRootKey(root, margin)
let shared = sharedObservers.get(key)
if (!shared) {
shared = createSharedObserver(root, margin)
sharedObservers.set(key, shared)
}
let targetCallbacks = shared.listeners.get(target)
if (!targetCallbacks) {
targetCallbacks = new Set()
shared.listeners.set(target, targetCallbacks)
shared.observer.observe(target)
}
targetCallbacks.add(callback)
return () => {
const current = shared?.listeners.get(target)
if (current) {
current.delete(callback)
if (current.size === 0) {
shared?.listeners.delete(target)
shared?.observer.unobserve(target)
}
}
if (shared && shared.listeners.size === 0) {
shared.observer.disconnect()
sharedObservers.delete(key)
}
}
}
interface VirtualItemProps {
cacheKey: string
children: JSX.Element
scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number
minPlaceholderHeight?: number
class?: string
contentClass?: string
placeholderClass?: string
virtualizationEnabled?: Accessor<boolean>
forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void
id?: string
}
export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children)
const cachedHeight = sizeCache.get(props.cacheKey)
const [isIntersecting, setIsIntersecting] = createSignal(true)
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
const flushVisibility = () => {
if (visibilityFrame !== null) {
cancelAnimationFrame(visibilityFrame)
visibilityFrame = null
}
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
}
const queueVisibility = (nextValue: boolean) => {
pendingVisibility = nextValue
if (visibilityFrame !== null) return
visibilityFrame = requestAnimationFrame(() => {
visibilityFrame = null
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
})
}
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
function cleanupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = undefined
}
}
function cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
intersectionCleanup = undefined
}
}
function persistMeasurement(nextHeight: number) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
if (shouldKeepPrevious) {
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
setHasMeasured(true)
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
return
}
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true)
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
}
setMeasuredHeight(normalized)
}
function updateMeasuredHeight() {
if (!contentRef || measurementsSuspended()) return
const next = contentRef.offsetHeight
if (next === measuredHeight()) return
persistMeasurement(next)
}
function setupResizeObserver() {
if (!contentRef || measurementsSuspended()) return
cleanupResizeObserver()
if (typeof ResizeObserver === "undefined") {
updateMeasuredHeight()
return
}
resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return
updateMeasuredHeight()
})
resizeObserver.observe(contentRef)
}
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver()
if (!wrapperRef) {
setIsIntersecting(true)
return
}
if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true)
return
}
const margin = props.threshold ?? DEFAULT_MARGIN_PX
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible)
})
}
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
}
function setContentRef(element: HTMLDivElement | null) {
contentRef = element ?? undefined
if (contentRef) {
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
updateMeasuredHeight()
setupResizeObserver()
})
} else {
cleanupResizeObserver()
}
}
createEffect(() => {
if (shouldHideContent() || measurementsSuspended()) {
cleanupResizeObserver()
} else if (contentRef) {
queueMicrotask(() => {
updateMeasuredHeight()
setupResizeObserver()
})
}
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
setHasMeasured(true)
} else {
setMeasuredHeight(0)
setHasMeasured(false)
}
})
createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
})
const placeholderHeight = createMemo(() => {
const seenHeight = measuredHeight()
if (seenHeight > 0) {
return seenHeight
}
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
})
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
flushVisibility()
})
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass]
if (shouldHideContent()) {
classes.push("virtual-item-content-hidden")
}
return classes.filter(Boolean).join(" ")
}
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null
return resolved()
})
return (
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
<div
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
}}
>
<div ref={setContentRef} class={contentClass()}>
{lazyContent()}
</div>
</div>
</div>
)
}

View File

@@ -1,23 +0,0 @@
import { isTauriHost } from "./runtime-env"
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
if (typeof window === "undefined") {
return
}
if (isTauriHost()) {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
return
} catch (error) {
console.warn(`[${context}] unable to open via system opener`, error)
}
}
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (error) {
console.warn(`[${context}] unable to open external url`, error)
}
}

View File

@@ -1,200 +0,0 @@
import { createLowlight, common } from "lowlight"
type AstNode = {
type: string
value?: string
children?: AstNode[]
startIndex?: number
endIndex?: number
lineNumber?: number
}
type SyntaxNodeEntry = {
node: AstNode
wrapper?: AstNode
}
type SyntaxFileLine = {
value: string
lineNumber: number
valueLength: number
nodeList: SyntaxNodeEntry[]
}
type LowlightApi = ReturnType<typeof createLowlight>
export function processAST(ast: { children: AstNode[] }) {
let lineNumber = 1
const syntaxObj: Record<number, SyntaxFileLine> = {}
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
nodes.forEach((node) => {
if (node.type === "text") {
const textValue = node.value ?? ""
if (!textValue.includes("\n")) {
const valueLength = textValue.length
if (!syntaxObj[lineNumber]) {
node.startIndex = 0
node.endIndex = valueLength - 1
syntaxObj[lineNumber] = {
value: textValue,
lineNumber,
valueLength,
nodeList: [{ node, wrapper }],
}
} else {
node.startIndex = syntaxObj[lineNumber].valueLength
node.endIndex = node.startIndex + valueLength - 1
syntaxObj[lineNumber].value += textValue
syntaxObj[lineNumber].valueLength += valueLength
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
}
node.lineNumber = lineNumber
return
}
const lines = textValue.split("\n")
node.children = node.children || []
for (let index = 0; index < lines.length; index++) {
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
const valueLength = value.length
const childNode: AstNode = {
type: "text",
value,
startIndex: Infinity,
endIndex: Infinity,
lineNumber: currentLineNumber,
}
if (!syntaxObj[currentLineNumber]) {
childNode.startIndex = 0
childNode.endIndex = valueLength - 1
syntaxObj[currentLineNumber] = {
value,
lineNumber: currentLineNumber,
valueLength,
nodeList: [{ node: childNode, wrapper }],
}
} else {
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
childNode.endIndex = childNode.startIndex + valueLength - 1
syntaxObj[currentLineNumber].value += value
syntaxObj[currentLineNumber].valueLength += valueLength
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
}
node.children.push(childNode)
}
node.lineNumber = lineNumber
return
}
if (node.children) {
loopAST(node.children, node)
node.lineNumber = lineNumber
}
})
}
loopAST(ast.children)
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
}
export function _getAST() {
return {}
}
const lowlight = createLowlight(common)
lowlight.register("vue", function hljsDefineVue(hljs: any) {
return {
subLanguage: "xml",
contains: [
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
{
begin: /^(\s*)(<script>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "javascript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "typescript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "css",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "scss",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "stylus",
excludeBegin: true,
excludeEnd: true,
},
],
}
})
let maxLineToIgnoreSyntax = 2000
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
export const highlighter = {
name: "lowlight",
get maxLineToIgnoreSyntax() {
return maxLineToIgnoreSyntax
},
setMaxLineToIgnoreSyntax(value: number) {
maxLineToIgnoreSyntax = value
},
get ignoreSyntaxHighlightList() {
return ignoreSyntaxHighlightList
},
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
ignoreSyntaxHighlightList.length = 0
ignoreSyntaxHighlightList.push(...values)
},
getAST(raw: string, fileName?: string, lang?: string) {
const language = typeof lang === "string" ? lang.trim() : ""
if (
fileName &&
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
) {
return undefined
}
if (language && lowlight.registered(language)) {
return lowlight.highlight(language, raw)
}
return lowlight.highlightAuto(raw)
},
processAST(ast: { children: AstNode[] }) {
return processAST(ast)
},
hasRegisteredCurrentLang(lang: string) {
return lowlight.registered(lang)
},
getHighlighterEngine(): LowlightApi {
return lowlight
},
type: "class" as const,
}
export const versions = "local-common"

View File

@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api" import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events" import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n" import { tGlobal } from "../i18n"
import { registerBehaviorCommands } from "../settings/behavior-registry" import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions") const log = getLogger("actions")
@@ -427,19 +427,178 @@ export function useCommands(options: UseCommandsOptions) {
}, },
}) })
registerBehaviorCommands((command) => commandRegistry.register(command), { commandRegistry.register({
preferences: options.preferences, id: "prompt-submit-shortcut",
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks, label: () =>
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints, options.preferences().promptSubmitOnEnter
toggleShowTimelineTools: options.toggleShowTimelineTools, ? tGlobal("commands.promptSubmitShortcut.label.swapped")
toggleUsageMetrics: options.toggleUsageMetrics, : tGlobal("commands.promptSubmitShortcut.label.default"),
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions, description: () => tGlobal("commands.promptSubmitShortcut.description"),
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter, category: "Input & Focus",
setDiffViewMode: options.setDiffViewMode, keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
setToolOutputExpansion: options.setToolOutputExpansion, action: options.togglePromptSubmitOnEnter,
setDiagnosticsExpansion: options.setDiagnosticsExpansion, })
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
setToolInputsVisibility: options.setToolInputsVisibility, commandRegistry.register({
id: "thinking",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions,
}) })
commandRegistry.register({ commandRegistry.register({

View File

@@ -1,158 +0,0 @@
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import {
containsFileDrop,
extractDroppedDirectoryPaths,
listenForNativeFolderDrops,
listenForNativeFolderDropState,
normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop,
} from "../native/desktop-file-drop"
import { runtimeEnv } from "../runtime-env"
interface UseFolderDropOptions {
enabled: Accessor<boolean>
onDrop: (paths: string[]) => void | Promise<void>
onInvalidDrop?: () => void
}
interface FolderDropBindings {
onDragEnter: (event: DragEvent) => void
onDragOver: (event: DragEvent) => void
onDragLeave: (event: DragEvent) => void
onDrop: (event: DragEvent) => void
}
export function useFolderDrop(options: UseFolderDropOptions): {
isActive: Accessor<boolean>
isSupported: boolean
bind: FolderDropBindings
} {
const [isActive, setIsActive] = createSignal(false)
const [dragDepth, setDragDepth] = createSignal(0)
const isSupported = supportsDesktopFolderDrop()
function reset() {
setDragDepth(0)
setIsActive(false)
}
async function handleResolvedPaths(paths: string[]) {
reset()
if (!options.enabled()) {
return
}
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
if (directoryPaths.length === 0) {
options.onInvalidDrop?.()
return
}
await options.onDrop(directoryPaths)
}
createEffect(() => {
if (!options.enabled()) {
reset()
}
})
onMount(() => {
if (!isSupported) {
return
}
let disposeNativeDrop = () => {}
let disposeNativeState = () => {}
void listenForNativeFolderDrops((paths) => {
if (!options.enabled()) {
return
}
void handleResolvedPaths(paths)
}).then((dispose) => {
disposeNativeDrop = dispose
})
void listenForNativeFolderDropState((state) => {
if (!options.enabled()) {
reset()
return
}
if (state === "enter") {
setIsActive(true)
return
}
reset()
}).then((dispose) => {
disposeNativeState = dispose
})
onCleanup(() => {
disposeNativeDrop()
disposeNativeState()
})
})
const bind: FolderDropBindings = {
onDragEnter(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
setDragDepth((prev) => prev + 1)
setIsActive(true)
},
onDragOver(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy"
}
setIsActive(true)
},
onDragLeave(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
return
}
event.preventDefault()
const nextDepth = Math.max(0, dragDepth() - 1)
setDragDepth(nextDepth)
if (nextDepth === 0) {
setIsActive(false)
}
},
onDrop(event) {
if (!isSupported) {
return
}
event.preventDefault()
event.stopPropagation()
if (!options.enabled()) {
reset()
return
}
if (runtimeEnv.host === "tauri") {
reset()
return
}
const paths = extractDroppedDirectoryPaths(event)
if (paths.length === 0) {
reset()
options.onInvalidDrop?.()
return
}
void handleResolvedPaths(paths)
},
}
return {
isActive,
isSupported,
bind,
}
}

View File

@@ -1,9 +1,9 @@
export const appMessages = { export const appMessages = {
"app.launchError.title": "Unable to launch OpenCode", "app.launchError.title": "Unable to launch OpenCode",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.", "app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
"app.launchError.binaryPathLabel": "Binary path", "app.launchError.binaryPathLabel": "Binary path",
"app.launchError.errorOutputLabel": "Error output", "app.launchError.errorOutputLabel": "Error output",
"app.launchError.openAdvancedSettings": "Open OpenCode Settings", "app.launchError.openAdvancedSettings": "Open Advanced Settings",
"app.launchError.close": "Close", "app.launchError.close": "Close",
"app.launchError.closeTitle": "Close (Esc)", "app.launchError.closeTitle": "Close (Esc)",
"app.launchError.fallbackMessage": "Failed to launch workspace", "app.launchError.fallbackMessage": "Failed to launch workspace",

View File

@@ -22,7 +22,6 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Opening...", "folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.advancedSettings": "Advanced Settings", "folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navigate", "folderSelection.hints.navigate": "Navigate",
"folderSelection.hints.select": "Select", "folderSelection.hints.select": "Select",
@@ -32,11 +31,6 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Starting instance...", "folderSelection.loading.title": "Starting instance...",
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.", "folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
"folderSelection.drop.title": "Drop a folder to open it",
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
"folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.", "folderSelection.dialog.description": "Select workspace to start coding.",
} as const } as const

View File

@@ -96,17 +96,11 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "The agent's roadmap for this session. Tracks tasks, subtasks, and their completion status.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Long-running processes started by the agent. You can monitor their output, stop, or terminate them.",
"instanceShell.rightPanel.sections.mcp": "MCP Servers", "instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol servers that extend the agent's capabilities with external tools and services.",
"instanceShell.rightPanel.sections.lsp": "LSP Servers", "instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocol servers providing code intelligence, diagnostics, and language-specific features.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins that customize the UI and server behavior, adding features beyond MCP and LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
"instanceShell.sessionChanges.loading": "Fetching session changes...", "instanceShell.sessionChanges.loading": "Fetching session changes...",
@@ -114,10 +108,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed", "instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes", "instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.filesShell.fileListTitle": "File list", "instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file", "instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file", "instanceShell.filesShell.mobileSelectorEmpty": "Select a file",

View File

@@ -23,6 +23,7 @@ export const messagingMessages = {
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",
"messageSection.quote.copied": "Copied!", "messageSection.quote.copied": "Copied!",
"messageSection.quote.copyFailed": "Copy failed", "messageSection.quote.copyFailed": "Copy failed",
"messageTimeline.ariaLabel": "Message timeline", "messageTimeline.ariaLabel": "Message timeline",
"messageTimeline.segment.user.label": "You", "messageTimeline.segment.user.label": "You",
"messageTimeline.segment.assistant.label": "Asst", "messageTimeline.segment.assistant.label": "Asst",
@@ -34,12 +35,13 @@ export const messagingMessages = {
"messageTimeline.tooltip.compaction.manual": "User Compaction", "messageTimeline.tooltip.compaction.manual": "User Compaction",
"messageTimeline.text.filePrefix": "[File] {filename}", "messageTimeline.text.filePrefix": "[File] {filename}",
"messageTimeline.text.attachment": "Attachment", "messageTimeline.text.attachment": "Attachment",
"messageBlock.tool.header": "Tool Call", "messageBlock.tool.header": "Tool Call",
"messageBlock.tool.unknown": "unknown", "messageBlock.tool.unknown": "unknown",
"messageBlock.tool.goToSession.label": "Go to Session", "messageBlock.tool.goToSession.label": "Go to Session",
"messageBlock.tool.goToSession.title": "Go to session", "messageBlock.tool.goToSession.title": "Go to session",
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet", "messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
"messageBlock.tool.deletePart.label": "Delete Part", "messageBlock.tool.deletePart.label": "Delete",
"messageBlock.tool.deletePart.deleting": "Deleting...", "messageBlock.tool.deletePart.deleting": "Deleting...",
"messageBlock.tool.deletePart.title": "Delete this tool call output", "messageBlock.tool.deletePart.title": "Delete this tool call output",
"messageBlock.tool.deletePart.failed.title": "Delete failed", "messageBlock.tool.deletePart.failed.title": "Delete failed",
@@ -69,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "You", "messageItem.speaker.you": "You",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revert", "messageItem.actions.revert": "Revert",
"messageItem.actions.revertTitle": "Undo changes up to here (deletes messages)", "messageItem.actions.revertTitle": "Revert to this message",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork from this message", "messageItem.actions.forkTitle": "Fork from this message",
"messageItem.actions.copy": "Copy", "messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message", "messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!", "messageItem.actions.copied": "Copied!",
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
"messageItem.actions.deletingMessage": "Deleting...",
"messageItem.actions.deleteMessageFailedTitle": "Delete failed",
"messageItem.actions.deleteMessageFailedMessage": "Failed to delete message",
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
"messageSection.bulkDelete.toolbarAriaLabel": "Selected items ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected items",
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
"messageSection.bulkDelete.moreOptionsTitle": "More options",
"messageSection.bulkDelete.selectionModeLabel": "Selection",
"messageSection.bulkDelete.selectionModeAll": "All",
"messageSection.bulkDelete.selectionModeTools": "Tools only",
"messageSection.bulkDelete.selectionHint.toggle": "Select item",
"messageSection.bulkDelete.selectionHint.range": "Select range",
"messageSection.bulkDelete.selectionHint.clear": "Clear Selection",
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
"messageSection.bulkDelete.failedTitle": "Delete failed",
"messageSection.bulkDelete.failedMessage": "Failed to delete selected items",
"messageItem.status.queued": "QUEUED", "messageItem.status.queued": "QUEUED",
"messageItem.status.generating": "Generating...", "messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...", "messageItem.status.sending": "Sending...",
"messageItem.status.failedToSend": "Message failed to send", "messageItem.status.failedToSend": "Message failed to send",
"messagePart.actions.delete": "Delete Part", "messagePart.actions.delete": "Delete",
"messagePart.actions.deleting": "Deleting...", "messagePart.actions.deleting": "Deleting...",
"messagePart.actions.deleteTitle": "Delete this item", "messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed", "messagePart.actions.deleteFailedTitle": "Delete failed",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Stop failed", "sessionView.alerts.abortFailed.title": "Stop failed",
"sessionView.alerts.revertFailed.message": "Failed to revert to message", "sessionView.alerts.revertFailed.message": "Failed to revert to message",
"sessionView.alerts.revertFailed.title": "Revert failed", "sessionView.alerts.revertFailed.title": "Revert failed",
"sessionView.alerts.deleteUpToFailed.message": "Failed to delete messages",
"sessionView.alerts.deleteUpToFailed.title": "Delete failed",
"sessionView.alerts.forkFailed.message": "Failed to fork session", "sessionView.alerts.forkFailed.message": "Failed to fork session",
"sessionView.alerts.forkFailed.title": "Fork failed", "sessionView.alerts.forkFailed.title": "Fork failed",
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text", "sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",

View File

@@ -55,88 +55,4 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Used", "contextUsagePanel.labels.used": "Used",
"contextUsagePanel.labels.available": "Avail", "contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--", "contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Disabled",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
"settings.behavior.thinking.title": "Thinking sections",
"settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.",
"settings.behavior.thinkingDefault.title": "Thinking default",
"settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.",
"settings.behavior.timelineTools.title": "Timeline tool calls",
"settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.",
"settings.behavior.diffView.title": "Diff view",
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
"settings.behavior.diffView.option.split": "Split",
"settings.behavior.diffView.option.unified": "Unified",
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
"settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.",
"settings.behavior.toolInputsVisibility.title": "Tool inputs visibility",
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
"settings.behavior.usageMetrics.title": "Token usage metrics",
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
} as const } as const

View File

@@ -1,9 +1,9 @@
export const appMessages = { export const appMessages = {
"app.launchError.title": "No se pudo iniciar OpenCode", "app.launchError.title": "No se pudo iniciar OpenCode",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.", "app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
"app.launchError.binaryPathLabel": "Ruta del binario", "app.launchError.binaryPathLabel": "Ruta del binario",
"app.launchError.errorOutputLabel": "Salida de error", "app.launchError.errorOutputLabel": "Salida de error",
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode", "app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
"app.launchError.close": "Cerrar", "app.launchError.close": "Cerrar",
"app.launchError.closeTitle": "Cerrar (Esc)", "app.launchError.closeTitle": "Cerrar (Esc)",
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace", "app.launchError.fallbackMessage": "No se pudo iniciar el workspace",

View File

@@ -22,7 +22,6 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Abriendo...", "folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navegar", "folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar", "folderSelection.hints.select": "Seleccionar",
@@ -32,11 +31,6 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Iniciando instancia...", "folderSelection.loading.title": "Iniciando instancia...",
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.", "folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
"folderSelection.dialog.title": "Seleccionar workspace", "folderSelection.dialog.title": "Seleccionar workspace",
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
} as const } as const

View File

@@ -48,7 +48,7 @@ export const instanceMessages = {
"instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos", "instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos",
"instanceShell.commandPalette.button": "Paleta de comandos", "instanceShell.commandPalette.button": "Paleta de comandos",
"instanceShell.connection.ariaLabel": "Conexión {status}", "instanceShell.connection.ariaLabel": "Connection {status}",
"instanceShell.connection.connected": "Conectada", "instanceShell.connection.connected": "Conectada",
"instanceShell.connection.connecting": "Conectando...", "instanceShell.connection.connecting": "Conectando...",
"instanceShell.connection.disconnected": "Desconectada", "instanceShell.connection.disconnected": "Desconectada",
@@ -90,33 +90,22 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado", "instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios", "instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Hoja de ruta del agente para esta sesión. Realiza el seguimiento de tareas, subtareas y su estado de finalización.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Procesos de larga duración iniciados por el agente. Puedes supervisar su salida, detenerlos o terminarlos.",
"instanceShell.rightPanel.sections.mcp": "Servidores MCP", "instanceShell.rightPanel.sections.mcp": "Servidores MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Servidores del Model Context Protocol (MCP) que amplían las capacidades del agente con herramientas y servicios externos.",
"instanceShell.rightPanel.sections.lsp": "Servidores LSP", "instanceShell.rightPanel.sections.lsp": "Servidores LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Servidores del Language Server Protocol (LSP) que proporcionan inteligencia de código, diagnósticos y funciones específicas del lenguaje.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.", "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.",
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...", "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...",
"instanceShell.sessionChanges.empty": "Aún no hay cambios.", "instanceShell.sessionChanges.empty": "Aun no hay cambios.",
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.filesShell.fileListTitle": "Lista de archivos", "instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo", "instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo", "instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Ir a sesión", "messageBlock.tool.goToSession.label": "Ir a sesión",
"messageBlock.tool.goToSession.title": "Ir a la sesión", "messageBlock.tool.goToSession.title": "Ir a la sesión",
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible", "messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
"messageBlock.tool.deletePart.label": "Eliminar parte", "messageBlock.tool.deletePart.label": "Eliminar",
"messageBlock.tool.deletePart.deleting": "Eliminando...", "messageBlock.tool.deletePart.deleting": "Eliminando...",
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta", "messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
"messageBlock.tool.deletePart.failed.title": "Error al eliminar", "messageBlock.tool.deletePart.failed.title": "Error al eliminar",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "Tú", "messageItem.speaker.you": "Tú",
"messageItem.speaker.assistant": "Asistente", "messageItem.speaker.assistant": "Asistente",
"messageItem.actions.revert": "Revertir", "messageItem.actions.revert": "Revertir",
"messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)", "messageItem.actions.revertTitle": "Revertir a este mensaje",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork desde este mensaje", "messageItem.actions.forkTitle": "Fork desde este mensaje",
"messageItem.actions.copy": "Copiar", "messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje", "messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!", "messageItem.actions.copied": "¡Copiado!",
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
"messageItem.actions.deletingMessage": "Eliminando...",
"messageItem.actions.deleteMessageFailedTitle": "Error al eliminar",
"messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje",
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
"messageSection.bulkDelete.toolbarAriaLabel": "Elementos seleccionados ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar elementos seleccionados",
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
"messageSection.bulkDelete.moreOptionsTitle": "Más opciones",
"messageSection.bulkDelete.selectionModeLabel": "Selección",
"messageSection.bulkDelete.selectionModeAll": "Todo",
"messageSection.bulkDelete.selectionModeTools": "Solo herramientas",
"messageSection.bulkDelete.selectionHint.toggle": "Seleccionar elemento",
"messageSection.bulkDelete.selectionHint.range": "Seleccionar rango",
"messageSection.bulkDelete.selectionHint.clear": "Borrar selección",
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los elementos seleccionados",
"messageItem.status.queued": "EN COLA", "messageItem.status.queued": "EN COLA",
"messageItem.status.generating": "Generando...", "messageItem.status.generating": "Generando...",
"messageItem.status.sending": "Enviando...", "messageItem.status.sending": "Enviando...",
"messageItem.status.failedToSend": "No se pudo enviar el mensaje", "messageItem.status.failedToSend": "No se pudo enviar el mensaje",
"messagePart.actions.delete": "Eliminar parte", "messagePart.actions.delete": "Eliminar",
"messagePart.actions.deleting": "Eliminando...", "messagePart.actions.deleting": "Eliminando...",
"messagePart.actions.deleteTitle": "Eliminar este elemento", "messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar", "messagePart.actions.deleteFailedTitle": "Error al eliminar",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "No se pudo detener", "sessionView.alerts.abortFailed.title": "No se pudo detener",
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje", "sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
"sessionView.alerts.revertFailed.title": "No se pudo revertir", "sessionView.alerts.revertFailed.title": "No se pudo revertir",
"sessionView.alerts.deleteUpToFailed.message": "No se pudieron eliminar los mensajes",
"sessionView.alerts.deleteUpToFailed.title": "Error al eliminar",
"sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión", "sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión",
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork", "sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado", "sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",

View File

@@ -55,88 +55,4 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Usado", "contextUsagePanel.labels.used": "Usado",
"contextUsagePanel.labels.available": "Disp.", "contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--", "contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactivado",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
"settings.behavior.thinking.title": "Secciones de pensamiento",
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
"settings.behavior.diffView.title": "Vista de diferencias",
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
"settings.behavior.diffView.option.split": "Dividida",
"settings.behavior.diffView.option.unified": "Unificada",
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
} as const } as const

View File

@@ -1,9 +1,9 @@
export const appMessages = { export const appMessages = {
"app.launchError.title": "Impossible de lancer OpenCode", "app.launchError.title": "Impossible de lancer OpenCode",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.", "app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.",
"app.launchError.binaryPathLabel": "Chemin du binaire", "app.launchError.binaryPathLabel": "Chemin du binaire",
"app.launchError.errorOutputLabel": "Sortie d'erreur", "app.launchError.errorOutputLabel": "Sortie d'erreur",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode", "app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
"app.launchError.close": "Fermer", "app.launchError.close": "Fermer",
"app.launchError.closeTitle": "Fermer (Esc)", "app.launchError.closeTitle": "Fermer (Esc)",
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail", "app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",

View File

@@ -22,7 +22,6 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Ouverture...", "folderSelection.browse.buttonOpening": "Ouverture...",
"folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Naviguer", "folderSelection.hints.navigate": "Naviguer",
"folderSelection.hints.select": "Sélectionner", "folderSelection.hints.select": "Sélectionner",
@@ -32,11 +31,6 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Démarrage de l'instance...", "folderSelection.loading.title": "Démarrage de l'instance...",
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.", "folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
"folderSelection.dialog.title": "Sélectionner l'espace de travail", "folderSelection.dialog.title": "Sélectionner l'espace de travail",
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
} as const } as const

View File

@@ -90,22 +90,15 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panneau d'état", "instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications", "instanceShell.rightPanel.tabs.changes": "Modifications",
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
"instanceShell.rightPanel.tabs.files": "Fichiers", "instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Feuille de route de l'agent pour cette session. Suit les tâches et leur statut d'achèvement.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Processus longs démarrés par l'agent. Vous pouvez surveiller leur sortie, les arrêter ou les terminer.",
"instanceShell.rightPanel.sections.mcp": "Serveurs MCP", "instanceShell.rightPanel.sections.mcp": "Serveurs MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Serveurs du protocole Model Context Protocol qui étendent les capacités de l'agent avec des outils externes.",
"instanceShell.rightPanel.sections.lsp": "Serveurs LSP", "instanceShell.rightPanel.sections.lsp": "Serveurs LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Serveurs du protocole Language Server Protocol fournissant l'intelligence de code et les diagnostics.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.",
"instanceShell.sessionChanges.loading": "Récupération des changements...", "instanceShell.sessionChanges.loading": "Récupération des changements...",
@@ -113,10 +106,6 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés", "instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
"instanceShell.sessionChanges.actions.show": "Afficher les changements", "instanceShell.sessionChanges.actions.show": "Afficher les changements",
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers", "instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier", "instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier", "instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Aller à la session", "messageBlock.tool.goToSession.label": "Aller à la session",
"messageBlock.tool.goToSession.title": "Aller à la session", "messageBlock.tool.goToSession.title": "Aller à la session",
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible", "messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
"messageBlock.tool.deletePart.label": "Supprimer la partie", "messageBlock.tool.deletePart.label": "Supprimer",
"messageBlock.tool.deletePart.deleting": "Suppression...", "messageBlock.tool.deletePart.deleting": "Suppression...",
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil", "messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
"messageBlock.tool.deletePart.failed.title": "Échec de suppression", "messageBlock.tool.deletePart.failed.title": "Échec de suppression",
@@ -71,38 +71,17 @@ export const messagingMessages = {
"messageItem.speaker.you": "Vous", "messageItem.speaker.you": "Vous",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revenir", "messageItem.actions.revert": "Revenir",
"messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)", "messageItem.actions.revertTitle": "Revenir à ce message",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork depuis ce message", "messageItem.actions.forkTitle": "Fork depuis ce message",
"messageItem.actions.copy": "Copier", "messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message", "messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !", "messageItem.actions.copied": "Copié !",
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
"messageItem.actions.deletingMessage": "Suppression...",
"messageItem.actions.deleteMessageFailedTitle": "Échec de suppression",
"messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message",
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
"messageSection.bulkDelete.toolbarAriaLabel": "Éléments sélectionnés ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les éléments sélectionnés",
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
"messageSection.bulkDelete.moreOptionsTitle": "Plus d'options",
"messageSection.bulkDelete.selectionModeLabel": "Sélection",
"messageSection.bulkDelete.selectionModeAll": "Tous",
"messageSection.bulkDelete.selectionModeTools": "Outils uniquement",
"messageSection.bulkDelete.selectionHint.toggle": "Selectionner un element",
"messageSection.bulkDelete.selectionHint.range": "Selectionner une plage",
"messageSection.bulkDelete.selectionHint.clear": "Effacer la selection",
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les éléments sélectionnés",
"messageItem.status.queued": "EN FILE", "messageItem.status.queued": "EN FILE",
"messageItem.status.generating": "Génération...", "messageItem.status.generating": "Génération...",
"messageItem.status.sending": "Envoi...", "messageItem.status.sending": "Envoi...",
"messageItem.status.failedToSend": "Échec de l'envoi du message", "messageItem.status.failedToSend": "Échec de l'envoi du message",
"messagePart.actions.delete": "Supprimer la partie", "messagePart.actions.delete": "Supprimer",
"messagePart.actions.deleting": "Suppression...", "messagePart.actions.deleting": "Suppression...",
"messagePart.actions.deleteTitle": "Supprimer cet élément", "messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression", "messagePart.actions.deleteFailedTitle": "Échec de suppression",

View File

@@ -67,8 +67,6 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt", "sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message", "sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
"sessionView.alerts.revertFailed.title": "Échec du retour", "sessionView.alerts.revertFailed.title": "Échec du retour",
"sessionView.alerts.deleteUpToFailed.message": "Impossible de supprimer les messages",
"sessionView.alerts.deleteUpToFailed.title": "Échec de suppression",
"sessionView.alerts.forkFailed.message": "Impossible de forker la session", "sessionView.alerts.forkFailed.message": "Impossible de forker la session",
"sessionView.alerts.forkFailed.title": "Échec du fork", "sessionView.alerts.forkFailed.title": "Échec du fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé", "sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",

View File

@@ -55,88 +55,4 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Utilisé", "contextUsagePanel.labels.used": "Utilisé",
"contextUsagePanel.labels.available": "Dispo", "contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--", "contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactive",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
"settings.behavior.thinking.title": "Sections de reflexion",
"settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.",
"settings.behavior.thinkingDefault.title": "Etat initial de la reflexion",
"settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.",
"settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie",
"settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.",
"settings.behavior.diffView.title": "Vue du diff",
"settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.",
"settings.behavior.diffView.option.split": "Scinde",
"settings.behavior.diffView.option.unified": "Unifie",
"settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils",
"settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.",
"settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics",
"settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.",
"settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils",
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
} as const } as const

View File

@@ -1,9 +1,9 @@
export const appMessages = { export const appMessages = {
"app.launchError.title": "OpenCode を起動できません", "app.launchError.title": "OpenCode を起動できません",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。", "app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
"app.launchError.binaryPathLabel": "バイナリのパス", "app.launchError.binaryPathLabel": "バイナリのパス",
"app.launchError.errorOutputLabel": "エラー出力", "app.launchError.errorOutputLabel": "エラー出力",
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く", "app.launchError.openAdvancedSettings": "詳細設定を開く",
"app.launchError.close": "閉じる", "app.launchError.close": "閉じる",
"app.launchError.closeTitle": "閉じる (Esc)", "app.launchError.closeTitle": "閉じる (Esc)",
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました", "app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",

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