Compare commits
38 Commits
v0.12.2-de
...
v0.12.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d |
95
.github/workflows/build-and-upload.yml
vendored
95
.github/workflows/build-and-upload.yml
vendored
@@ -28,6 +28,21 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
upload_actions_artifacts:
|
||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
actions_artifacts_retention_days:
|
||||
description: "Retention (days) for GitHub Actions artifacts"
|
||||
required: false
|
||||
default: 7
|
||||
type: number
|
||||
actions_artifacts_name_prefix:
|
||||
description: "Optional prefix for Actions artifact names"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -203,6 +218,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -244,6 +268,15 @@ jobs:
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Electron Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -286,6 +319,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -339,7 +381,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -350,6 +392,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -414,7 +465,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -425,6 +476,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -492,7 +552,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -505,6 +565,15 @@ jobs:
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -582,7 +651,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -608,6 +677,15 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||
path: packages/tauri-app/release-tauri/*
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -766,3 +844,12 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||
path: packages/electron-app/release/*.rpm
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
119
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
119
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: Comment PR Artifacts
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
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 }}
|
||||
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' }}
|
||||
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}`);
|
||||
56
.github/workflows/pr-build.yml
vendored
Normal file
56
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: PR Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
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' }}
|
||||
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
|
||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
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
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3253,9 +3253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3314,6 +3314,7 @@
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
@@ -3321,6 +3322,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -10234,14 +10244,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tauri-plugin-keepawake-api": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -10982,6 +10984,36 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.48.8",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0",
|
||||
"solid-js": ">=1.0",
|
||||
"svelte": ">=5.0",
|
||||
"vue": ">=3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
@@ -12001,7 +12033,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12038,7 +12070,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12079,16 +12111,15 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12098,6 +12129,8 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
@@ -12110,7 +12143,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -31,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.11.4",
|
||||
"minServerVersion": "0.12.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
|
||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||
if (!Array.isArray(paths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directories = paths.filter((value): value is string => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
@@ -12,6 +12,14 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.14"
|
||||
"@opencode-ai/plugin": "1.2.24"
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -46,4 +46,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
2433
packages/tauri-app/Cargo.lock
generated
2433
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -13,7 +13,6 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -91,6 +92,15 @@ function ensureUiBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerUiBundle() {
|
||||
console.log("[prebuild] syncing server public UI bundle...")
|
||||
execSync(serverPrepareUiCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -246,6 +256,7 @@ function copyUiLoadingAssets() {
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.12.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -19,9 +19,12 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","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","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"]}}
|
||||
|
||||
@@ -2378,36 +2378,6 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2378,36 +2378,6 @@
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -17,10 +19,24 @@ use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_spawn(command: &mut Command) {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn configure_spawn(_command: &mut Command) {}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
@@ -36,6 +52,46 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn configure_posix_process_group(command: &mut Command) {
|
||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||
// processes (login shell/tsx) without leaving the server orphaned.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setpgid(0, 0) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||
if force {
|
||||
args.push("/F".to_string());
|
||||
}
|
||||
|
||||
let mut command = Command::new("taskkill");
|
||||
command.args(&args);
|
||||
configure_spawn(&mut command);
|
||||
|
||||
match command.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
combined.contains("not found") || combined.contains("no running instance")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -348,11 +404,19 @@ impl CliProcessManager {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
let pid = child.id() as i32;
|
||||
// Prefer signaling the process group to avoid orphaning children
|
||||
// when the CLI was launched via a wrapper shell.
|
||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !kill_process_tree_windows(child.id(), false) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -368,11 +432,17 @@ impl CliProcessManager {
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
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)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -450,9 +520,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
@@ -462,9 +535,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
@@ -537,7 +613,24 @@ impl CliProcessManager {
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
@@ -828,14 +921,31 @@ impl CliEntry {
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("ELECTRON_RENDERER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http-port".to_string());
|
||||
args.push("0".to_string());
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push(ui_dev_server);
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
args.push(log_level);
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https".to_string());
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
@@ -12,11 +15,31 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(windows)]
|
||||
use std::iter;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Clone)]
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct WakeLockConfig {
|
||||
display: bool,
|
||||
idle: bool,
|
||||
sleep: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -35,6 +58,38 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
config: Option<WakeLockConfig>,
|
||||
) -> Result<(), String> {
|
||||
let config = config.unwrap_or(WakeLockConfig {
|
||||
display: true,
|
||||
idle: false,
|
||||
sleep: false,
|
||||
});
|
||||
|
||||
let mut builder = keepawake::Builder::default();
|
||||
builder
|
||||
.display(config.display)
|
||||
.idle(config.idle)
|
||||
.sleep(config.sleep)
|
||||
.reason("CodeNomad active session")
|
||||
.app_name("CodeNomad")
|
||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||
|
||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
*state_lock = Some(wake_lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
state_lock.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
@@ -46,7 +101,10 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// This must be treated as an internal origin or the navigation guard will
|
||||
// redirect it to the system browser and the app will appear blank.
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||
"http" | "https" => matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -66,6 +124,55 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|path| match std::fs::metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, ());
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_folder_drop_event(
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
event_name: &str,
|
||||
paths: &[std::path::PathBuf],
|
||||
) {
|
||||
let directories = collect_directory_paths(paths);
|
||||
|
||||
if directories.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_windows_app_user_model_id() {
|
||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect();
|
||||
|
||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||
if result < 0 {
|
||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -74,13 +181,14 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -92,7 +200,12 @@ fn main() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
@@ -187,6 +300,27 @@ fn main() {
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||
..
|
||||
} => {
|
||||
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
@@ -234,13 +368,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N")
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||
.text(
|
||||
if is_mac { "close" } else { "quit" },
|
||||
if is_mac { "Close" } else { "Quit" },
|
||||
)
|
||||
.build()?;
|
||||
submenus.push(file_menu);
|
||||
|
||||
@@ -263,7 +400,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.separator()
|
||||
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.build()?;
|
||||
@@ -277,9 +413,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||
.iter()
|
||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||
.collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"version": "0.12.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -18,8 +18,10 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -30,7 +32,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43,4 +45,4 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "./stores/sessions"
|
||||
|
||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||
import { openSettings } from "./stores/settings-screen"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -77,8 +78,6 @@ const App: Component = () => {
|
||||
setToolInputsVisibility,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
@@ -252,7 +251,6 @@ const App: Component = () => {
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
@@ -274,7 +272,7 @@ const App: Component = () => {
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
openSettings("opencode")
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
@@ -487,7 +485,6 @@ const App: Component = () => {
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -533,10 +530,6 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -546,12 +539,8 @@ const App: Component = () => {
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onClose={() => {
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
/>
|
||||
@@ -559,7 +548,7 @@ const App: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
<SettingsScreen />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
|
||||
@@ -2,32 +2,32 @@ import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
@@ -193,6 +193,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
function dropTargetBlocked() {
|
||||
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
||||
}
|
||||
|
||||
function showInvalidFolderDropAlert() {
|
||||
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
||||
title: t("folderSelection.drop.invalidTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const folderDrop = useFolderDrop({
|
||||
enabled: () => !dropTargetBlocked(),
|
||||
onInvalidDrop: showInvalidFolderDropAlert,
|
||||
onDrop: async (paths) => {
|
||||
const firstPath = paths[0]
|
||||
if (!firstPath) {
|
||||
showInvalidFolderDropAlert()
|
||||
return
|
||||
}
|
||||
handleFolderSelect(firstPath)
|
||||
},
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -210,11 +235,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -237,11 +257,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -317,6 +332,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
onDragEnter={folderDrop.bind.onDragEnter}
|
||||
onDragOver={folderDrop.bind.onDragOver}
|
||||
onDragLeave={folderDrop.bind.onDragLeave}
|
||||
onDrop={folderDrop.bind.onDrop}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
@@ -367,16 +386,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("appearance")}
|
||||
aria-label={t("settings.open.title")}
|
||||
title={t("settings.open.title")}
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("remote")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -396,7 +423,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -404,13 +431,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
@@ -418,7 +445,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
@@ -427,7 +454,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -435,9 +462,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
@@ -564,12 +589,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
{/* OpenCode settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
@@ -619,16 +644,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
||||
<div class="folder-drop-overlay" aria-hidden="true">
|
||||
<div class="folder-drop-card">
|
||||
<FolderPlus class="w-8 h-8 icon-muted" />
|
||||
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
||||
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
/>
|
||||
|
||||
<DirectoryBrowserDialog
|
||||
open={isFolderBrowserOpen()}
|
||||
title={t("folderSelection.dialog.title")}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||
return notificationsEnabled()
|
||||
? t("settings.notifications.status.enabled")
|
||||
: t("settings.notifications.status.disabled")
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
<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
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => openSettings("notifications")}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import UnifiedPicker from "./unified-picker"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||
@@ -13,12 +14,41 @@ import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import { usePromptState } from "./prompt-input/usePromptState"
|
||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
const log = getLogger("actions")
|
||||
|
||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
||||
if (!text || attachments.length === 0) return []
|
||||
|
||||
const usedCounters = new Set<string>()
|
||||
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
||||
const counter = match?.[1]
|
||||
if (counter) usedCounters.add(counter)
|
||||
}
|
||||
|
||||
if (usedCounters.size === 0) return []
|
||||
|
||||
const consumed = new Set<string>()
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment?.id) continue
|
||||
if (attachment?.source?.type !== "text") continue
|
||||
const display = attachment.display
|
||||
if (typeof display !== "string") continue
|
||||
const match = display.match(pastedDisplayCounterRegex)
|
||||
if (!match?.[1]) continue
|
||||
if (usedCounters.has(match[1])) {
|
||||
consumed.add(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(consumed)
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const { t } = useI18n()
|
||||
const [, setIsFocused] = createSignal(false)
|
||||
@@ -246,7 +276,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
commandName.length > 0 &&
|
||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||
|
||||
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
||||
const resolvedPrompt = isKnownSlashCommand
|
||||
? resolvedCommandArgs
|
||||
? `${commandToken} ${resolvedCommandArgs}`
|
||||
: commandToken
|
||||
: resolvePastedPlaceholders(text, currentAttachments)
|
||||
const historyEntry = resolvedPrompt
|
||||
|
||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||
@@ -262,6 +297,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
||||
for (const attachmentId of consumedIds) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
@@ -281,7 +320,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else if (isKnownSlashCommand) {
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, currentAttachments)
|
||||
}
|
||||
|
||||
107
packages/ui/src/components/settings-screen.tsx
Normal file
107
packages/ui/src/components/settings-screen.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||
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
|
||||
@@ -122,55 +122,28 @@ export interface VirtualFollowListProps<T> {
|
||||
}
|
||||
|
||||
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key)
|
||||
const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId)
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||
const bottomSentinel = () => bottomSentinelSignal()
|
||||
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 isLoading = () => Boolean(props.loading?.())
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||
|
||||
const [anchorLock, setAnchorLock] = createSignal<{ key: string; block: ScrollLogicalPosition } | null>(null)
|
||||
|
||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let shellRef: HTMLDivElement | undefined
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let pendingAnchorCorrectionFrame: number | null = null
|
||||
let pendingScrollCompensationScheduled = false
|
||||
let pendingScrollCompensations = new Map<string, number>()
|
||||
let scrollCompensationGen = 0
|
||||
let pendingActiveScroll = false
|
||||
let userScrollIntentUntil = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let lastResetKey: string | number | undefined
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingInitialScroll = true
|
||||
let scrollToBottomFrame: number | null = null
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
|
||||
let lastKnownScrollTop = 0
|
||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let lastResetKey: string | number | undefined
|
||||
|
||||
const state: VirtualFollowListState = {
|
||||
autoScroll,
|
||||
@@ -181,7 +154,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
const now = performance.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
if (direction) {
|
||||
lastUserScrollIntentDirection = direction
|
||||
@@ -189,8 +162,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
return performance.now() <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
@@ -231,670 +203,189 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateScrollIndicatorsFromVisibility() {
|
||||
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
|
||||
const bottomVisible = bottomSentinelVisible()
|
||||
const topVisible = topSentinelVisible()
|
||||
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||
setShowScrollTopButton(hasItems && !topVisible)
|
||||
}
|
||||
setShowScrollBottomButton(hasItems && !atBottom)
|
||||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
function clearScrollToBottomFrames() {
|
||||
if (scrollToBottomFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomFrame)
|
||||
scrollToBottomFrame = null
|
||||
}
|
||||
if (scrollToBottomDelayedFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||
scrollToBottomDelayedFrame = null
|
||||
// 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 = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||
if (!containerRef) return
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||
if (suppressAutoAnchor) {
|
||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||
const handle = virtuaHandle()
|
||||
if (!handle) return
|
||||
if (options?.suppressAutoAnchor ?? !immediate) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
|
||||
setAutoScroll(true)
|
||||
}
|
||||
|
||||
function requestScrollToBottom(immediate = true) {
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (!containerRef || !bottomSentinel()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
pendingActiveScroll = false
|
||||
clearScrollToBottomFrames()
|
||||
scrollToBottomFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomFrame = null
|
||||
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomDelayedFrame = null
|
||||
scrollToBottom(immediate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePendingActiveScroll() {
|
||||
if (!pendingActiveScroll) return
|
||||
if (!isActive()) return
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
function scrollToTop(immediate = true) {
|
||||
const handle = virtuaHandle()
|
||||
if (!handle) return
|
||||
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
|
||||
setAutoScroll(false)
|
||||
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||
}
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
function clearAnchorLock() {
|
||||
setAnchorLock(null)
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
pendingAnchorCorrectionFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function computeDesiredOffset(block: ScrollLogicalPosition, container: HTMLElement, anchorRect: DOMRect) {
|
||||
if (block === "end") {
|
||||
return Math.max(0, container.clientHeight - anchorRect.height)
|
||||
}
|
||||
if (block === "center") {
|
||||
return Math.max(0, container.clientHeight / 2 - anchorRect.height / 2)
|
||||
}
|
||||
// Default to start.
|
||||
return 0
|
||||
}
|
||||
|
||||
function applyAnchorCorrection() {
|
||||
const lock = anchorLock()
|
||||
if (!lock) return
|
||||
if (autoScroll()) return
|
||||
if (!containerRef) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const anchorId = getAnchorId(lock.key)
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (!anchor) return
|
||||
|
||||
const containerRect = containerRef.getBoundingClientRect()
|
||||
const anchorRect = anchor.getBoundingClientRect()
|
||||
const currentOffset = anchorRect.top - containerRect.top
|
||||
const desiredOffset = computeDesiredOffset(lock.block, containerRef, anchorRect)
|
||||
const delta = currentOffset - desiredOffset
|
||||
if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) {
|
||||
return
|
||||
}
|
||||
const nextTop = containerRef.scrollTop + delta
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
containerRef.scrollTop = Math.min(maxScrollTop, Math.max(0, nextTop))
|
||||
}
|
||||
|
||||
function scheduleAnchorCorrection() {
|
||||
if (pendingAnchorCorrectionFrame !== null) return
|
||||
pendingAnchorCorrectionFrame = requestAnimationFrame(() => {
|
||||
pendingAnchorCorrectionFrame = null
|
||||
applyAnchorCorrection()
|
||||
})
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (autoScroll() && !anchorLock()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const previousScrollTop = lastKnownScrollTop
|
||||
const currentScrollTop = containerRef.scrollTop
|
||||
const deltaScrollTop = currentScrollTop - previousScrollTop
|
||||
if (currentScrollTop !== lastKnownScrollTop) {
|
||||
lastKnownScrollTop = currentScrollTop
|
||||
if (isUserScroll) {
|
||||
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
const atBottom = bottomSentinelVisible()
|
||||
}
|
||||
updateScrollButtons()
|
||||
props.onScroll?.()
|
||||
|
||||
const beforeAutoScroll = autoScroll()
|
||||
|
||||
const inferredDirection: "up" | "down" | null =
|
||||
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
|
||||
|
||||
// If the user scrolls manually, exit key-anchored mode.
|
||||
if (isUserScroll && anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
|
||||
if (isUserScroll) {
|
||||
// If the user is actively scrolling upward, exit follow-to-bottom mode
|
||||
// immediately. The bottom sentinel can remain "visible" for a short
|
||||
// distance due to its observer margin, which otherwise keeps autoScroll
|
||||
// enabled and makes the list feel stuck.
|
||||
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
setAutoScroll(false)
|
||||
}
|
||||
|
||||
// Do not re-enable follow mode while the user's current scroll intent
|
||||
// is upward. This prevents transient anchor/pin scrolls from pulling
|
||||
// the list back into autoScroll(true).
|
||||
if (inferredDirection !== "up") {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
} else if (!atBottom && autoScroll()) {
|
||||
// If the user is scrolling up and we are no longer at the bottom,
|
||||
// ensure follow mode is disabled.
|
||||
setAutoScroll(false)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
props.onScroll?.()
|
||||
})
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
setScrollElement(containerRef)
|
||||
props.onScrollElementChange?.(containerRef)
|
||||
attachScrollIntentListeners(containerRef)
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
lastUserScrollIntentDirection = null
|
||||
if (!containerRef) {
|
||||
return
|
||||
}
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
function scheduleScrollCompensation(key: string, delta: number) {
|
||||
if (!containerRef) return
|
||||
if (!delta || !Number.isFinite(delta)) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
// Only compensate while the user scrolls upward (testing default).
|
||||
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") return
|
||||
if (autoScroll() || anchorLock()) return
|
||||
|
||||
const anchorId = getAnchorId(key)
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (!anchor) return
|
||||
const containerRect = containerRef.getBoundingClientRect()
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
// Determine whether the item was fully above the viewport *before* the
|
||||
// height delta applied. Items can expand downward into the viewport; in that
|
||||
// case we still need to compensate to keep existing visible content stable.
|
||||
const bottomAfter = rect.bottom
|
||||
const bottomBefore = bottomAfter - delta
|
||||
const wasAboveViewport = bottomBefore < containerRect.top
|
||||
if (!wasAboveViewport) return
|
||||
|
||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||
pendingScrollCompensations.set(key, next)
|
||||
|
||||
if (pendingScrollCompensationScheduled) return
|
||||
pendingScrollCompensationScheduled = true
|
||||
const gen = scrollCompensationGen
|
||||
|
||||
// Flush in a microtask so compensation lands before the next paint.
|
||||
queueMicrotask(() => {
|
||||
if (gen !== scrollCompensationGen) return
|
||||
pendingScrollCompensationScheduled = false
|
||||
if (!containerRef) return
|
||||
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") {
|
||||
pendingScrollCompensations = new Map()
|
||||
return
|
||||
}
|
||||
if (autoScroll() || anchorLock()) {
|
||||
pendingScrollCompensations = new Map()
|
||||
return
|
||||
}
|
||||
|
||||
let applied = 0
|
||||
let count = 0
|
||||
for (const pendingDelta of pendingScrollCompensations.values()) {
|
||||
if (!pendingDelta) continue
|
||||
applied += pendingDelta
|
||||
count += 1
|
||||
}
|
||||
pendingScrollCompensations = new Map()
|
||||
if (!applied) return
|
||||
|
||||
const before = containerRef.scrollTop
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
const nextTop = Math.min(maxScrollTop, Math.max(0, before + applied))
|
||||
if (nextTop !== before) {
|
||||
containerRef.scrollTop = nextTop
|
||||
lastKnownScrollTop = nextTop
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let pendingAutoPin = false
|
||||
let pendingAutoPinFrame: number | null = null
|
||||
|
||||
function clearPendingAutoPinFrame() {
|
||||
if (pendingAutoPinFrame !== null) {
|
||||
cancelAnimationFrame(pendingAutoPinFrame)
|
||||
pendingAutoPinFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutoPinToBottom() {
|
||||
if (!containerRef) return false
|
||||
if (!autoScroll()) return false
|
||||
if (anchorLock()) return false
|
||||
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
if (containerRef.scrollTop !== maxScrollTop) {
|
||||
containerRef.scrollTop = maxScrollTop
|
||||
lastKnownScrollTop = maxScrollTop
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleAutoPinToBottom() {
|
||||
if (!containerRef) return
|
||||
if (pendingAutoPin) return
|
||||
pendingAutoPin = true
|
||||
clearPendingAutoPinFrame()
|
||||
const gen = scrollCompensationGen
|
||||
|
||||
// Flush in a microtask so adjustments land before the next paint,
|
||||
// then re-apply on the next two frames to catch deferred layout.
|
||||
queueMicrotask(() => {
|
||||
if (gen !== scrollCompensationGen) return
|
||||
pendingAutoPin = false
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
applyAutoPinToBottom()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function setShellRef(element: HTMLDivElement | null) {
|
||||
shellRef = element || undefined
|
||||
setShellElement(shellRef)
|
||||
props.onShellElementChange?.(shellRef)
|
||||
}
|
||||
|
||||
function setBottomSentinel(element: HTMLDivElement | null) {
|
||||
setBottomSentinelSignal(element)
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
const api: VirtualFollowListApi = {
|
||||
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
|
||||
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||
scrollToKey: (key, opts) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchorId = getAnchorId(key)
|
||||
const behavior = opts?.behavior ?? "smooth"
|
||||
const block = opts?.block ?? "start"
|
||||
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
|
||||
if (index === -1) return
|
||||
const nextAutoScroll = opts?.setAutoScroll ?? false
|
||||
setAutoScroll(nextAutoScroll)
|
||||
if (!nextAutoScroll) {
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
setAnchorLock({ key, block })
|
||||
} else {
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
}
|
||||
const first = document.getElementById(anchorId)
|
||||
first?.scrollIntoView({ block, behavior })
|
||||
// When using virtualization, the placeholder height can be stale until the
|
||||
// item mounts/measures. Re-run scrollIntoView() on the next frame to
|
||||
// stabilize the final position.
|
||||
requestAnimationFrame(() => {
|
||||
const second = document.getElementById(anchorId)
|
||||
second?.scrollIntoView({ block, behavior })
|
||||
})
|
||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||
},
|
||||
notifyContentRendered: () => {
|
||||
if (autoScroll()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
notifyContentRendered: () => handleContentRendered(),
|
||||
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
||||
getAutoScroll: () => autoScroll(),
|
||||
getScrollElement: () => scrollElement(),
|
||||
getShellElement: () => shellElement(),
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.registerApi?.(api)
|
||||
})
|
||||
createEffect(() => props.registerApi?.(api))
|
||||
createEffect(() => props.registerState?.(state))
|
||||
|
||||
createEffect(() => {
|
||||
props.registerState?.(state)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const nextKey = props.resetKey?.()
|
||||
if (nextKey === undefined) return
|
||||
if (lastResetKey === undefined) {
|
||||
lastResetKey = nextKey
|
||||
return
|
||||
// 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
|
||||
|
||||
// Reset internal state when consumers swap datasets (e.g. session switch).
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
pendingAnchorCorrectionFrame = null
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
|
||||
scrollCompensationGen += 1
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
pendingAutoPin = false
|
||||
clearPendingAutoPinFrame()
|
||||
|
||||
suppressAutoScrollOnce = false
|
||||
pendingActiveScroll = false
|
||||
setAutoScroll(initialAutoScroll())
|
||||
pendingInitialScroll = true
|
||||
}))
|
||||
|
||||
setAnchorLock(null)
|
||||
setActiveKey(null)
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setTopSentinelVisible(true)
|
||||
setBottomSentinelVisible(true)
|
||||
setAutoScroll(Boolean(initialAutoScroll()))
|
||||
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
lastUserScrollIntentDirection = null
|
||||
})
|
||||
|
||||
let lastActiveState = false
|
||||
// Initial scroll and session activation
|
||||
createEffect(() => {
|
||||
const active = isActive()
|
||||
if (active) {
|
||||
resolvePendingActiveScroll()
|
||||
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
|
||||
requestScrollToBottom(true)
|
||||
|
||||
// When switching back to a cached session pane, items can mount/measure
|
||||
// after the initial scroll jump. Re-pin once layout settles so the
|
||||
// viewport stays at the bottom.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scheduleAutoPinToBottom()
|
||||
})
|
||||
})
|
||||
if (!active) return
|
||||
if (pendingInitialScroll && props.items().length > 0) {
|
||||
pendingInitialScroll = false
|
||||
if (initialScrollToBottom()) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||
pendingActiveScroll = true
|
||||
}
|
||||
lastActiveState = active
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const loading = isLoading()
|
||||
if (loading) {
|
||||
// Keep the initial scroll pending while loading so we can
|
||||
// anchor to the bottom as soon as items appear.
|
||||
pendingInitialScroll = true
|
||||
}
|
||||
|
||||
if (!pendingInitialScroll) return
|
||||
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || props.items().length === 0) return
|
||||
|
||||
if (!initialScrollToBottom()) {
|
||||
// An outer component is responsible for restoring scroll.
|
||||
pendingInitialScroll = false
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we're in follow-to-bottom mode for the initial position.
|
||||
if (anchorLock()) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
setAutoScroll(true)
|
||||
|
||||
pendingInitialScroll = false
|
||||
// Scroll synchronously so the first paint prefers bottom content.
|
||||
scrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousFollowToken: string | number | undefined
|
||||
createEffect(() => {
|
||||
const token = props.followToken?.()
|
||||
if (token === undefined) {
|
||||
previousFollowToken = token
|
||||
return
|
||||
}
|
||||
if (previousFollowToken === undefined) {
|
||||
previousFollowToken = token
|
||||
return
|
||||
}
|
||||
if (token === previousFollowToken) {
|
||||
return
|
||||
}
|
||||
previousFollowToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
scrollToBottom(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Drop anchor lock if the anchored key is removed.
|
||||
createEffect(() => {
|
||||
const lock = anchorLock()
|
||||
if (!lock) return
|
||||
const keys = props.items().map((item, idx) => props.getKey(item, idx))
|
||||
if (!keys.includes(lock.key)) {
|
||||
clearAnchorLock()
|
||||
}
|
||||
})
|
||||
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>
|
||||
|
||||
createEffect(() => {
|
||||
if (props.items().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
return
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
})
|
||||
<Show when={props.renderOverlay}>
|
||||
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
|
||||
</Show>
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const topTarget = topSentinel()
|
||||
const bottomTarget = bottomSentinel()
|
||||
if (!container || !topTarget || !bottomTarget) return
|
||||
if (typeof IntersectionObserver === "undefined") return
|
||||
<Show when={props.renderControls}>
|
||||
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
|
||||
</Show>
|
||||
|
||||
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let visibilityChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.target === topTarget) {
|
||||
setTopSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
} else if (entry.target === bottomTarget) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
}
|
||||
<Show
|
||||
when={
|
||||
!props.renderControls &&
|
||||
(showScrollTopButton() || showScrollBottomButton()) &&
|
||||
props.scrollToTopAriaLabel &&
|
||||
props.scrollToBottomAriaLabel
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` },
|
||||
)
|
||||
observer.observe(topTarget)
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const items = props.items()
|
||||
if (!container || items.length === 0) return
|
||||
if (typeof document === "undefined") return
|
||||
if (typeof IntersectionObserver === "undefined") return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const anchorId = (best.target as HTMLElement).id
|
||||
const key = getKeyFromAnchorId(anchorId)
|
||||
setActiveKey((current) => (current === key ? current : key))
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||
)
|
||||
|
||||
const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx)))
|
||||
anchorIds.forEach((anchorId) => {
|
||||
const anchor = document.getElementById(anchorId)
|
||||
if (anchor) observer.observe(anchor)
|
||||
})
|
||||
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const key = activeKey()
|
||||
props.onActiveKeyChange?.(key)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
if (pendingAnchorCorrectionFrame !== null) {
|
||||
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||
}
|
||||
scrollCompensationGen += 1
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
clearPendingAutoPinFrame()
|
||||
clearScrollToBottomFrames()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
})
|
||||
|
||||
const controls = () => {
|
||||
if (props.renderControls) {
|
||||
return props.renderControls(state, api)
|
||||
}
|
||||
|
||||
// Avoid hardcoded user-visible strings; require consumers to supply
|
||||
// localized aria labels when using the default controls.
|
||||
if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const labelTop = props.scrollToTopAriaLabel()
|
||||
const labelBottom = props.scrollToBottomAriaLabel()
|
||||
return (
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
|
||||
<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(false, { suppressAutoAnchor: false })}
|
||||
aria-label={labelBottom}
|
||||
>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
@@ -902,71 +393,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="message-stream-shell" ref={setShellRef}>
|
||||
<div
|
||||
class="message-stream"
|
||||
ref={setContainerRef}
|
||||
onScroll={handleScroll}
|
||||
onMouseUp={(event) => props.onMouseUp?.(event)}
|
||||
onClick={(event) => props.onClick?.(event)}
|
||||
>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
{props.renderBeforeItems?.()}
|
||||
<Index each={props.items()}>
|
||||
{(item, index) => {
|
||||
const key = () => props.getKey(item(), index)
|
||||
const anchorId = () => getAnchorId(key())
|
||||
const overscanPx = props.overscanPx ?? 800
|
||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||
return (
|
||||
<VirtualItem
|
||||
id={anchorId()}
|
||||
cacheKey={key()}
|
||||
scrollContainer={scrollElement}
|
||||
threshold={overscanPx}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={itemVirtualizationEnabled}
|
||||
suspendMeasurements={suspendMeasurements}
|
||||
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||
const delta = nextHeight - previousHeight
|
||||
|
||||
// Follow mode: keep the viewport pinned to the bottom as
|
||||
// items mount/measure and change height.
|
||||
if (delta && autoScroll() && !anchorLock()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
// Key-anchored mode: keep the target key in view when
|
||||
// items above it mount/measure and shift layout.
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
return
|
||||
}
|
||||
|
||||
// Free-scroll mode: if items above the viewport change height
|
||||
// while scrolling upward, compensate scrollTop so visible
|
||||
// content stays stable.
|
||||
if (delta) {
|
||||
if (meta.isStaleCacheCorrection) return
|
||||
scheduleScrollCompensation(key(), delta)
|
||||
}
|
||||
}}
|
||||
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<div ref={setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</div>
|
||||
|
||||
{controls()}
|
||||
|
||||
{props.renderOverlay?.()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
const sizeCache = new Map<string, number>()
|
||||
const DEFAULT_MARGIN_PX = 600
|
||||
const MIN_PLACEHOLDER_HEIGHT = 400
|
||||
const VISIBILITY_BUFFER_PX = 0
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Above the root: compare bottom edge to root top.
|
||||
if (entry.boundingClientRect.bottom < rootBounds.top) {
|
||||
const distance = rootBounds.top - entry.boundingClientRect.bottom
|
||||
return distance <= VISIBILITY_BUFFER_PX
|
||||
}
|
||||
|
||||
// Below the root: compare top edge to root bottom.
|
||||
if (entry.boundingClientRect.top > rootBounds.bottom) {
|
||||
const distance = entry.boundingClientRect.top - rootBounds.bottom
|
||||
return distance <= VISIBILITY_BUFFER_PX
|
||||
}
|
||||
|
||||
// Overlapping the root bounds.
|
||||
return true
|
||||
}
|
||||
|
||||
function getViewportRect(): { top: number; bottom: number } {
|
||||
if (typeof window === "undefined") {
|
||||
return { top: 0, bottom: 0 }
|
||||
}
|
||||
return { top: 0, bottom: window.innerHeight }
|
||||
}
|
||||
|
||||
function isRenderableRoot(root: ObserverRoot): boolean {
|
||||
if (!root) return true
|
||||
if (root instanceof Document) return true
|
||||
if (typeof window === "undefined") return false
|
||||
|
||||
const element = root as Element
|
||||
const style = window.getComputedStyle(element as Element)
|
||||
if (style.display === "none" || style.visibility === "hidden") {
|
||||
return false
|
||||
}
|
||||
const rect = (element as Element).getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0
|
||||
}
|
||||
|
||||
function shouldRenderByRects(params: {
|
||||
wrapperRect: DOMRect
|
||||
rootRect: { top: number; bottom: number }
|
||||
margin: number
|
||||
}): boolean {
|
||||
const { wrapperRect, rootRect, margin } = params
|
||||
const threshold = margin + VISIBILITY_BUFFER_PX
|
||||
|
||||
// Above the root: compare bottom edge to root top.
|
||||
if (wrapperRect.bottom < rootRect.top) {
|
||||
const distance = rootRect.top - wrapperRect.bottom
|
||||
return distance <= threshold
|
||||
}
|
||||
|
||||
// Below the root: compare top edge to root bottom.
|
||||
if (wrapperRect.top > rootRect.bottom) {
|
||||
const distance = wrapperRect.top - rootRect.bottom
|
||||
return distance <= threshold
|
||||
}
|
||||
|
||||
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 | (() => 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
|
||||
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface VirtualItemHeightChangeMeta {
|
||||
source: "initial-visible-measure" | "resize"
|
||||
previousCachedHeight: number | null
|
||||
isStaleCacheCorrection: boolean
|
||||
wasHidden: boolean
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
// Default to hidden until we can determine visibility.
|
||||
// This avoids keeping heavy DOM alive when IntersectionObserver
|
||||
// doesn't fire (common for hidden/zero-sized scroll roots).
|
||||
const [isIntersecting, setIsIntersecting] = createSignal(false)
|
||||
// Keep measuredHeight aligned with the *effective layout height* while hidden.
|
||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
let pendingVisibility: boolean | null = null
|
||||
let visibilityFrame: number | null = null
|
||||
let awaitingVisibleMeasurement = true
|
||||
let lastMeasurementWhileHidden = true
|
||||
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 measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (forceVisible()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
|
||||
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 scheduleVisibleMeasurements() {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
queueMicrotask(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupIntersectionObserver() {
|
||||
if (intersectionCleanup) {
|
||||
intersectionCleanup()
|
||||
intersectionCleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||
return
|
||||
}
|
||||
const before = measuredHeight()
|
||||
const normalized = nextHeight
|
||||
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
|
||||
const previous = previousCachedHeight ?? measuredHeight()
|
||||
const measurementMeta: VirtualItemHeightChangeMeta = {
|
||||
source: meta?.source ?? "resize",
|
||||
previousCachedHeight,
|
||||
isStaleCacheCorrection:
|
||||
(meta?.source ?? "resize") === "initial-visible-measure" &&
|
||||
previousCachedHeight !== null &&
|
||||
normalized > 0 &&
|
||||
Math.abs(normalized - previousCachedHeight) > 1,
|
||||
wasHidden: meta?.wasHidden ?? shouldHideContent(),
|
||||
}
|
||||
// Only keep the previous measurement when the element reports 0 height.
|
||||
// Allow shrinkage so placeholder height matches real content height;
|
||||
// keeping the max height can cause mount/unmount jitter near the
|
||||
// virtualization boundary.
|
||||
const shouldKeepPrevious = previous > 0 && normalized === 0
|
||||
if (shouldKeepPrevious) {
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
sizeCache.set(props.cacheKey, previous)
|
||||
setMeasuredHeight(previous)
|
||||
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||
return
|
||||
}
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
}
|
||||
setMeasuredHeight(normalized)
|
||||
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||
}
|
||||
|
||||
function updateMeasuredHeight() {
|
||||
if (!contentRef) return
|
||||
if (measurementsSuspended()) return
|
||||
// Prefer subpixel-accurate height for scroll compensation.
|
||||
// offsetHeight rounds to integers which can accumulate error.
|
||||
const rect = contentRef.getBoundingClientRect()
|
||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||
const currentMeasured = measuredHeight()
|
||||
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||
const wasHidden = lastMeasurementWhileHidden
|
||||
if (measurementSource === "initial-visible-measure") {
|
||||
awaitingVisibleMeasurement = false
|
||||
lastMeasurementWhileHidden = false
|
||||
}
|
||||
if (next === currentMeasured) return
|
||||
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||
}
|
||||
|
||||
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(false)
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
|
||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||
|
||||
// If the scroll root is hidden / 0x0, IntersectionObserver can report
|
||||
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
|
||||
// which keeps heavy DOM alive in background tabs.
|
||||
//
|
||||
// In that state, force-hide and skip attaching the observer. When the
|
||||
// pane becomes visible again, VirtualItem will re-run this setup and
|
||||
// re-attach the observer.
|
||||
const renderable = isRenderableRoot(targetRoot)
|
||||
if (!renderable) {
|
||||
setIsIntersecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid doing an eager geometry read here.
|
||||
// During large list hydration / initial layout, wrapper rects can be
|
||||
// transiently 0/incorrect and cause many offscreen items to mount.
|
||||
// Rely on the observer callback (which we harden below) to determine
|
||||
// visibility.
|
||||
|
||||
const wrapperEl = wrapperRef
|
||||
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
|
||||
// IntersectionObserver can produce transient false-positives during pane
|
||||
// activation/layout transitions (e.g. `isIntersecting: true` for items far
|
||||
// outside the scroll root). For element roots, prefer explicit rect math.
|
||||
if (targetRoot && !(targetRoot instanceof Document)) {
|
||||
// When rootBounds is null we cannot trust the entry; treat as hidden.
|
||||
if (entry.rootBounds === null) {
|
||||
queueVisibility(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rootRect = (targetRoot as Element).getBoundingClientRect()
|
||||
const visible = shouldRenderByRects({
|
||||
wrapperRect: wrapperEl.getBoundingClientRect(),
|
||||
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
|
||||
margin,
|
||||
})
|
||||
queueVisibility(visible)
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the entry-based heuristic.
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const hidden = shouldHideContent()
|
||||
if (hidden) {
|
||||
awaitingVisibleMeasurement = true
|
||||
lastMeasurementWhileHidden = true
|
||||
}
|
||||
if (hidden || measurementsSuspended()) {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||
scheduleVisibleMeasurements()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const key = props.cacheKey
|
||||
|
||||
const cached = sizeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
setMeasuredHeight(cached)
|
||||
} else {
|
||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||
}
|
||||
})
|
||||
|
||||
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 resolveContent()
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
23
packages/ui/src/lib/external-url.ts
Normal file
23
packages/ui/src/lib/external-url.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
|
||||
import { requestData } from "../opencode-api"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { registerBehaviorCommands } from "../settings/behavior-registry"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -427,178 +427,19 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "prompt-submit-shortcut",
|
||||
label: () =>
|
||||
options.preferences().promptSubmitOnEnter
|
||||
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
||||
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
||||
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
||||
category: "Input & Focus",
|
||||
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
||||
action: options.togglePromptSubmitOnEnter,
|
||||
})
|
||||
|
||||
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,
|
||||
registerBehaviorCommands((command) => commandRegistry.register(command), {
|
||||
preferences: options.preferences,
|
||||
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools: options.toggleShowTimelineTools,
|
||||
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||
setDiffViewMode: options.setDiffViewMode,
|
||||
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
|
||||
setToolInputsVisibility: options.setToolInputsVisibility,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
|
||||
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"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 Advanced Settings.",
|
||||
"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.binaryPathLabel": "Binary path",
|
||||
"app.launchError.errorOutputLabel": "Error output",
|
||||
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
|
||||
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
|
||||
"app.launchError.close": "Close",
|
||||
"app.launchError.closeTitle": "Close (Esc)",
|
||||
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "Opening...",
|
||||
|
||||
"folderSelection.advancedSettings": "Advanced Settings",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "Navigate",
|
||||
"folderSelection.hints.select": "Select",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Starting instance...",
|
||||
"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.description": "Select workspace to start coding.",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "Used",
|
||||
"contextUsagePanel.labels.available": "Avail",
|
||||
"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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"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 Configuración avanzada.",
|
||||
"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.binaryPathLabel": "Ruta del binario",
|
||||
"app.launchError.errorOutputLabel": "Salida de error",
|
||||
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
|
||||
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
|
||||
"app.launchError.close": "Cerrar",
|
||||
"app.launchError.closeTitle": "Cerrar (Esc)",
|
||||
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||
|
||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "Navegar",
|
||||
"folderSelection.hints.select": "Seleccionar",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Iniciando instancia...",
|
||||
"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.description": "Selecciona un workspace para empezar a programar.",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "Usado",
|
||||
"contextUsagePanel.labels.available": "Disp.",
|
||||
"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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"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 avancés.",
|
||||
"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.binaryPathLabel": "Chemin du binaire",
|
||||
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
||||
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
|
||||
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
|
||||
"app.launchError.close": "Fermer",
|
||||
"app.launchError.closeTitle": "Fermer (Esc)",
|
||||
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||
|
||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "Naviguer",
|
||||
"folderSelection.hints.select": "Sélectionner",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||
"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.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "Utilisé",
|
||||
"contextUsagePanel.labels.available": "Dispo",
|
||||
"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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"app.launchError.title": "OpenCode を起動できません",
|
||||
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
|
||||
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
|
||||
"app.launchError.binaryPathLabel": "バイナリのパス",
|
||||
"app.launchError.errorOutputLabel": "エラー出力",
|
||||
"app.launchError.openAdvancedSettings": "詳細設定を開く",
|
||||
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
|
||||
"app.launchError.close": "閉じる",
|
||||
"app.launchError.closeTitle": "閉じる (Esc)",
|
||||
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "開いています...",
|
||||
|
||||
"folderSelection.advancedSettings": "詳細設定",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "移動",
|
||||
"folderSelection.hints.select": "選択",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||
|
||||
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||
|
||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "使用",
|
||||
"contextUsagePanel.labels.available": "残り",
|
||||
"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": "無効",
|
||||
"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": "操作",
|
||||
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
||||
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
|
||||
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
|
||||
"settings.behavior.thinking.title": "思考セクション",
|
||||
"settings.behavior.thinking.subtitle": "メッセージ内のAIの思考セクションを表示/非表示にします。",
|
||||
"settings.behavior.thinkingDefault.title": "思考の既定",
|
||||
"settings.behavior.thinkingDefault.subtitle": "思考セクションを最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||
"settings.behavior.timelineTools.title": "タイムラインのツール呼び出し",
|
||||
"settings.behavior.timelineTools.subtitle": "メッセージタイムラインでツール呼び出しを表示/非表示にします。",
|
||||
"settings.behavior.diffView.title": "差分表示",
|
||||
"settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。",
|
||||
"settings.behavior.diffView.option.split": "分割",
|
||||
"settings.behavior.diffView.option.unified": "統合",
|
||||
"settings.behavior.toolOutputsDefault.title": "ツール出力の既定",
|
||||
"settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||
"settings.behavior.diagnosticsDefault.title": "診断の既定",
|
||||
"settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||
"settings.behavior.toolInputsVisibility.title": "ツール入力の表示",
|
||||
"settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。",
|
||||
"settings.behavior.usageMetrics.title": "トークン使用量メトリクス",
|
||||
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
||||
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
||||
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||
} as const
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"app.launchError.title": "Не удалось запустить OpenCode",
|
||||
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.",
|
||||
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
||||
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
||||
"app.launchError.openAdvancedSettings": "Открыть расширенные настройки",
|
||||
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
|
||||
"app.launchError.close": "Закрыть",
|
||||
"app.launchError.closeTitle": "Закрыть (Esc)",
|
||||
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "Открытие…",
|
||||
|
||||
"folderSelection.advancedSettings": "Расширенные настройки",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "Навигация",
|
||||
"folderSelection.hints.select": "Выбрать",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||
|
||||
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||
|
||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "Использовано",
|
||||
"contextUsagePanel.labels.available": "Доступно",
|
||||
"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": "Отключено",
|
||||
"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": "Взаимодействие",
|
||||
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
|
||||
"settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш",
|
||||
"settings.behavior.keyboardHints.subtitle": "Показывать подсказки сочетаний клавиш по всему интерфейсу.",
|
||||
"settings.behavior.thinking.title": "Разделы размышлений",
|
||||
"settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.",
|
||||
"settings.behavior.thinkingDefault.title": "Размышления по умолчанию",
|
||||
"settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.",
|
||||
"settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне",
|
||||
"settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.",
|
||||
"settings.behavior.diffView.title": "Вид диффа",
|
||||
"settings.behavior.diffView.subtitle": "Выберите, как отображаются диффы вызовов инструментов.",
|
||||
"settings.behavior.diffView.option.split": "Раздельный",
|
||||
"settings.behavior.diffView.option.unified": "Единый",
|
||||
"settings.behavior.toolOutputsDefault.title": "Выводы инструментов по умолчанию",
|
||||
"settings.behavior.toolOutputsDefault.subtitle": "Выберите, начинать ли выводы инструментов развернутыми или свернутыми.",
|
||||
"settings.behavior.diagnosticsDefault.title": "Диагностика по умолчанию",
|
||||
"settings.behavior.diagnosticsDefault.subtitle": "Выберите, начинать ли вывод диагностики развернутым или свернутым.",
|
||||
"settings.behavior.toolInputsVisibility.title": "Видимость входных данных инструмента",
|
||||
"settings.behavior.toolInputsVisibility.subtitle": "Задайте видимость по умолчанию для входных аргументов вызовов инструментов.",
|
||||
"settings.behavior.usageMetrics.title": "Метрики использования токенов",
|
||||
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
|
||||
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
|
||||
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
||||
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
||||
} as const
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const appMessages = {
|
||||
"app.launchError.title": "无法启动 OpenCode",
|
||||
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在“高级设置”中选择其他可执行文件。",
|
||||
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
|
||||
"app.launchError.binaryPathLabel": "可执行文件路径",
|
||||
"app.launchError.errorOutputLabel": "错误输出",
|
||||
"app.launchError.openAdvancedSettings": "打开高级设置",
|
||||
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
|
||||
"app.launchError.close": "关闭",
|
||||
"app.launchError.closeTitle": "关闭 (Esc)",
|
||||
"app.launchError.fallbackMessage": "启动工作区失败",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.browse.buttonOpening": "正在打开...",
|
||||
|
||||
"folderSelection.advancedSettings": "高级设置",
|
||||
"folderSelection.opencode": "OpenCode",
|
||||
|
||||
"folderSelection.hints.navigate": "导航",
|
||||
"folderSelection.hints.select": "选择",
|
||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "正在启动实例...",
|
||||
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
||||
|
||||
"folderSelection.drop.title": "拖放文件夹以打开",
|
||||
"folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。",
|
||||
"folderSelection.drop.invalidTitle": "无法打开拖放的项目",
|
||||
"folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。",
|
||||
|
||||
"folderSelection.dialog.title": "选择工作区",
|
||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
||||
} as const
|
||||
|
||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
||||
"contextUsagePanel.labels.used": "已用",
|
||||
"contextUsagePanel.labels.available": "可用",
|
||||
"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": "已禁用",
|
||||
"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": "交互",
|
||||
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
|
||||
"settings.behavior.keyboardHints.title": "键盘快捷键提示",
|
||||
"settings.behavior.keyboardHints.subtitle": "在整个界面中显示键盘快捷键提示。",
|
||||
"settings.behavior.thinking.title": "思考区块",
|
||||
"settings.behavior.thinking.subtitle": "在消息中显示或隐藏AI的思考区块。",
|
||||
"settings.behavior.thinkingDefault.title": "思考默认状态",
|
||||
"settings.behavior.thinkingDefault.subtitle": "选择思考区块默认是展开还是折叠。",
|
||||
"settings.behavior.timelineTools.title": "时间线工具调用",
|
||||
"settings.behavior.timelineTools.subtitle": "在消息时间线中显示或隐藏工具调用条目。",
|
||||
"settings.behavior.diffView.title": "差异视图",
|
||||
"settings.behavior.diffView.subtitle": "选择工具调用差异的显示方式。",
|
||||
"settings.behavior.diffView.option.split": "分栏",
|
||||
"settings.behavior.diffView.option.unified": "统一",
|
||||
"settings.behavior.toolOutputsDefault.title": "工具输出默认状态",
|
||||
"settings.behavior.toolOutputsDefault.subtitle": "选择工具输出默认是展开还是折叠。",
|
||||
"settings.behavior.diagnosticsDefault.title": "诊断默认状态",
|
||||
"settings.behavior.diagnosticsDefault.subtitle": "选择诊断输出默认是展开还是折叠。",
|
||||
"settings.behavior.toolInputsVisibility.title": "工具输入可见性",
|
||||
"settings.behavior.toolInputsVisibility.subtitle": "设置工具调用输入参数的默认可见性。",
|
||||
"settings.behavior.usageMetrics.title": "令牌用量指标",
|
||||
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
|
||||
"settings.behavior.autoCleanup.title": "自动清理空会话",
|
||||
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
||||
"settings.behavior.promptSubmit.title": "回车发送",
|
||||
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
||||
} as const
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
const log = getLogger("actions")
|
||||
@@ -15,9 +16,8 @@ export async function restartCli(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
|
||||
if (tauri?.invoke) {
|
||||
await tauri.invoke("cli_restart")
|
||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||
await invoke("cli_restart")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
146
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
146
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type NativeFolderDropState = "enter" | "leave"
|
||||
|
||||
interface TauriFolderDropPayload {
|
||||
paths?: unknown
|
||||
}
|
||||
|
||||
function normalizePathList(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return []
|
||||
}
|
||||
return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
}
|
||||
|
||||
function getFilePath(file: File): string | null {
|
||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||
return file.path
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||
return electronPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (!api?.getDirectoryPaths || paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await api.getDirectoryPaths(paths)
|
||||
} catch (error) {
|
||||
log.error("[native] failed to validate dropped directory paths", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsDesktopFolderDrop(): boolean {
|
||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||
}
|
||||
|
||||
export function containsFileDrop(event: DragEvent): boolean {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types) {
|
||||
return false
|
||||
}
|
||||
return Array.from(types).includes("Files")
|
||||
}
|
||||
|
||||
export function extractDroppedDirectoryPaths(event: DragEvent): string[] {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directoryHints = new Set<string>()
|
||||
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||
if (item.kind !== "file") {
|
||||
continue
|
||||
}
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
if (!entry?.isDirectory) {
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
const filePath = file ? getFilePath(file) : null
|
||||
if (filePath) {
|
||||
directoryHints.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = new Set<string>()
|
||||
for (const file of Array.from(dataTransfer.files ?? [])) {
|
||||
const filePath = getFilePath(file)
|
||||
if (!filePath) {
|
||||
continue
|
||||
}
|
||||
if (directoryHints.size > 0 && !directoryHints.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
paths.add(filePath)
|
||||
}
|
||||
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0)))
|
||||
if (uniquePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
return resolveElectronDirectoryPaths(uniquePaths)
|
||||
}
|
||||
return uniquePaths
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const unlisten = await listen("desktop:folder-drop", (event) => {
|
||||
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||
const paths = normalizePathList(payload.paths)
|
||||
if (paths.length > 0) {
|
||||
onDrop(paths)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unlisten()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop event", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||
listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||
listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||
])
|
||||
return () => {
|
||||
unlistenEnter()
|
||||
unlistenLeave()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop state", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,21 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
import { getLogger } from "../../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (
|
||||
options: {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: { name?: string; extensions: string[] }[]
|
||||
directory?: boolean
|
||||
multiple?: boolean
|
||||
},
|
||||
) => Promise<string | string[] | null>
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
dialog?: TauriDialogModule
|
||||
}
|
||||
|
||||
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
|
||||
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
|
||||
const dialogApi = tauriBridge?.dialog
|
||||
if (!dialogApi?.open) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dialogApi.open({
|
||||
const response = await open({
|
||||
title: options.title,
|
||||
defaultPath: options.defaultPath,
|
||||
directory: options.mode === "directory",
|
||||
multiple: false,
|
||||
filters: options.filters?.map((filter) => ({
|
||||
name: filter.name,
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
})),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
@@ -60,8 +61,7 @@ function hasAnyWakeLockSupport(): boolean {
|
||||
if (api?.setWakeLock) return true
|
||||
}
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
// We'll attempt dynamic import; treat as potentially supported.
|
||||
return true
|
||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||
}
|
||||
return Boolean((navigator as any)?.wakeLock?.request)
|
||||
}
|
||||
@@ -84,21 +84,18 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
|
||||
|
||||
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
const mod = await import("tauri-plugin-keepawake-api")
|
||||
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
|
||||
const stop = (mod as any).stop as (() => Promise<void>) | undefined
|
||||
if (!start || !stop) {
|
||||
if (!hasAnyWakeLockSupport()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// Plugin config supports toggling display/idle/sleep. Use a conservative
|
||||
// default to keep both system + display awake.
|
||||
await start({ display: true, idle: true, sleep: true })
|
||||
// Match Electron's prevent-display-sleep behavior by keeping the display
|
||||
// awake without blocking explicit system sleep requests.
|
||||
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
|
||||
return true
|
||||
}
|
||||
|
||||
await stop()
|
||||
await invoke("wake_lock_stop")
|
||||
return false
|
||||
} catch (error) {
|
||||
log.log("[wake-lock] tauri wake lock failed", error)
|
||||
@@ -137,13 +134,12 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const ok = await applyWakeLock(target)
|
||||
// Treat disable attempts as applied even if the underlying API doesn't exist.
|
||||
applied = target
|
||||
applied = target ? ok : false
|
||||
return ok
|
||||
} finally {
|
||||
inFlight = null
|
||||
// If desired changed while in-flight, re-apply once.
|
||||
if (desired !== applied) {
|
||||
if (desired !== target) {
|
||||
void setWakeLockDesired(desired)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,14 @@ export interface RuntimeEnvironment {
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface TauriCoreModule {
|
||||
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: unknown
|
||||
__TAURI__?: {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
dialog?: {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
}
|
||||
core?: TauriCoreModule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
452
packages/ui/src/lib/settings/behavior-registry.ts
Normal file
452
packages/ui/src/lib/settings/behavior-registry.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type {
|
||||
Preferences,
|
||||
ExpansionPreference,
|
||||
ToolInputsVisibilityPreference,
|
||||
} from "../../stores/preferences"
|
||||
import type { Command } from "../commands"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
export type BehaviorSettingKind = "toggle" | "enum"
|
||||
|
||||
export type BehaviorToggleSetting = {
|
||||
kind: "toggle"
|
||||
id: string
|
||||
titleKey: string
|
||||
subtitleKey: string
|
||||
get: (preferences: Preferences) => boolean
|
||||
set: (next: boolean) => void
|
||||
disabled?: () => boolean
|
||||
}
|
||||
|
||||
export type BehaviorEnumSetting<T extends string = string> = {
|
||||
kind: "enum"
|
||||
id: string
|
||||
titleKey: string
|
||||
subtitleKey: string
|
||||
get: (preferences: Preferences) => T
|
||||
set: (next: T) => void
|
||||
options: Array<{ value: T; labelKey: string }>
|
||||
disabled?: () => boolean
|
||||
}
|
||||
|
||||
export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
|
||||
|
||||
export type BehaviorRegistryActions = {
|
||||
preferences: Accessor<Preferences>
|
||||
updatePreferences?: (updates: Partial<Preferences>) => void
|
||||
toggleShowThinkingBlocks: () => void
|
||||
toggleKeyboardShortcutHints: () => void
|
||||
toggleShowTimelineTools: () => void
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
togglePromptSubmitOnEnter: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||
}
|
||||
|
||||
function splitKeywords(key: string): string[] {
|
||||
return tGlobal(key)
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: boolean) {
|
||||
if (getCurrent() === next) return
|
||||
toggle()
|
||||
}
|
||||
|
||||
export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] {
|
||||
const prefs = actions.preferences
|
||||
const updatePreferences = actions.updatePreferences
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.keyboardShortcutHints",
|
||||
titleKey: "settings.behavior.keyboardHints.title",
|
||||
subtitleKey: "settings.behavior.keyboardHints.subtitle",
|
||||
get: (p) => Boolean(p.showKeyboardShortcutHints ?? true),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ showKeyboardShortcutHints: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().showKeyboardShortcutHints ?? true),
|
||||
actions.toggleKeyboardShortcutHints,
|
||||
next,
|
||||
)
|
||||
},
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.thinkingBlocks",
|
||||
titleKey: "settings.behavior.thinking.title",
|
||||
subtitleKey: "settings.behavior.thinking.subtitle",
|
||||
get: (p) => Boolean(p.showThinkingBlocks),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ showThinkingBlocks: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().showThinkingBlocks),
|
||||
actions.toggleShowThinkingBlocks,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.thinkingBlocksDefault",
|
||||
titleKey: "settings.behavior.thinkingDefault.title",
|
||||
subtitleKey: "settings.behavior.thinkingDefault.subtitle",
|
||||
get: (p) => (p.thinkingBlocksExpansion ?? "expanded") as ExpansionPreference,
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ thinkingBlocksExpansion: next as ExpansionPreference })
|
||||
return
|
||||
}
|
||||
actions.setThinkingBlocksExpansion(next as ExpansionPreference)
|
||||
},
|
||||
options: [
|
||||
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.timelineToolCalls",
|
||||
titleKey: "settings.behavior.timelineTools.title",
|
||||
subtitleKey: "settings.behavior.timelineTools.subtitle",
|
||||
get: (p) => Boolean(p.showTimelineTools),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ showTimelineTools: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().showTimelineTools),
|
||||
actions.toggleShowTimelineTools,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.diffViewMode",
|
||||
titleKey: "settings.behavior.diffView.title",
|
||||
subtitleKey: "settings.behavior.diffView.subtitle",
|
||||
get: (p) => (p.diffViewMode ?? "split") as "split" | "unified",
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ diffViewMode: next as "split" | "unified" })
|
||||
return
|
||||
}
|
||||
actions.setDiffViewMode(next as "split" | "unified")
|
||||
},
|
||||
options: [
|
||||
{ value: "split", labelKey: "settings.behavior.diffView.option.split" },
|
||||
{ value: "unified", labelKey: "settings.behavior.diffView.option.unified" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.toolOutputsDefault",
|
||||
titleKey: "settings.behavior.toolOutputsDefault.title",
|
||||
subtitleKey: "settings.behavior.toolOutputsDefault.subtitle",
|
||||
get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference,
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ toolOutputExpansion: next as ExpansionPreference })
|
||||
return
|
||||
}
|
||||
actions.setToolOutputExpansion(next as ExpansionPreference)
|
||||
},
|
||||
options: [
|
||||
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.diagnosticsDefault",
|
||||
titleKey: "settings.behavior.diagnosticsDefault.title",
|
||||
subtitleKey: "settings.behavior.diagnosticsDefault.subtitle",
|
||||
get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference,
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ diagnosticsExpansion: next as ExpansionPreference })
|
||||
return
|
||||
}
|
||||
actions.setDiagnosticsExpansion(next as ExpansionPreference)
|
||||
},
|
||||
options: [
|
||||
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.toolInputsVisibility",
|
||||
titleKey: "settings.behavior.toolInputsVisibility.title",
|
||||
subtitleKey: "settings.behavior.toolInputsVisibility.subtitle",
|
||||
get: (p) => (p.toolInputsVisibility ?? "hidden") as ToolInputsVisibilityPreference,
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ toolInputsVisibility: next as ToolInputsVisibilityPreference })
|
||||
return
|
||||
}
|
||||
actions.setToolInputsVisibility(next as ToolInputsVisibilityPreference)
|
||||
},
|
||||
options: [
|
||||
{ value: "hidden", labelKey: "commands.common.hidden" },
|
||||
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.usageMetrics",
|
||||
titleKey: "settings.behavior.usageMetrics.title",
|
||||
subtitleKey: "settings.behavior.usageMetrics.subtitle",
|
||||
get: (p) => Boolean(p.showUsageMetrics ?? true),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ showUsageMetrics: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().showUsageMetrics ?? true),
|
||||
actions.toggleUsageMetrics,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.autoCleanupBlankSessions",
|
||||
titleKey: "settings.behavior.autoCleanup.title",
|
||||
subtitleKey: "settings.behavior.autoCleanup.subtitle",
|
||||
get: (p) => Boolean(p.autoCleanupBlankSessions),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ autoCleanupBlankSessions: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().autoCleanupBlankSessions),
|
||||
actions.toggleAutoCleanupBlankSessions,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptSubmitOnEnter",
|
||||
titleKey: "settings.behavior.promptSubmit.title",
|
||||
subtitleKey: "settings.behavior.promptSubmit.subtitle",
|
||||
get: (p) => Boolean(p.promptSubmitOnEnter),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ promptSubmitOnEnter: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().promptSubmitOnEnter),
|
||||
actions.togglePromptSubmitOnEnter,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] {
|
||||
return [
|
||||
{
|
||||
id: "prompt-submit-shortcut",
|
||||
label: () =>
|
||||
actions.preferences().promptSubmitOnEnter
|
||||
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
||||
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
||||
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
||||
category: "Input & Focus",
|
||||
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
||||
action: actions.togglePromptSubmitOnEnter,
|
||||
},
|
||||
{
|
||||
id: "thinking",
|
||||
label: () =>
|
||||
tGlobal(
|
||||
actions.preferences().showThinkingBlocks
|
||||
? "commands.thinkingBlocks.label.hide"
|
||||
: "commands.thinkingBlocks.label.show",
|
||||
),
|
||||
description: () => tGlobal("commands.thinkingBlocks.description"),
|
||||
category: "System",
|
||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
||||
action: actions.toggleShowThinkingBlocks,
|
||||
},
|
||||
{
|
||||
id: "timeline-tools",
|
||||
label: () =>
|
||||
tGlobal(
|
||||
actions.preferences().showTimelineTools
|
||||
? "commands.timelineToolCalls.label.hide"
|
||||
: "commands.timelineToolCalls.label.show",
|
||||
),
|
||||
description: () => tGlobal("commands.timelineToolCalls.description"),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
||||
action: actions.toggleShowTimelineTools,
|
||||
},
|
||||
{
|
||||
id: "keyboard-shortcut-hints",
|
||||
label: () =>
|
||||
tGlobal(
|
||||
actions.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: actions.toggleKeyboardShortcutHints,
|
||||
},
|
||||
{
|
||||
id: "thinking-default-visibility",
|
||||
label: () => {
|
||||
const mode = actions.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 = actions.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
actions.setThinkingBlocksExpansion(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff-view-split",
|
||||
label: () => {
|
||||
const prefix = (actions.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
||||
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
||||
},
|
||||
description: () => tGlobal("commands.diffViewSplit.description"),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
||||
action: () => actions.setDiffViewMode("split"),
|
||||
},
|
||||
{
|
||||
id: "diff-view-unified",
|
||||
label: () => {
|
||||
const prefix = (actions.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
||||
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
||||
},
|
||||
description: () => tGlobal("commands.diffViewUnified.description"),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
||||
action: () => actions.setDiffViewMode("unified"),
|
||||
},
|
||||
{
|
||||
id: "tool-output-default-visibility",
|
||||
label: () => {
|
||||
const mode = actions.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 = actions.preferences().toolOutputExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
actions.setToolOutputExpansion(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diagnostics-default-visibility",
|
||||
label: () => {
|
||||
const mode = actions.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 = actions.preferences().diagnosticsExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
actions.setDiagnosticsExpansion(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tool-inputs-visibility",
|
||||
label: () => {
|
||||
const mode = actions.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 = actions.preferences().toolInputsVisibility || "hidden"
|
||||
const next: ToolInputsVisibilityPreference =
|
||||
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
|
||||
actions.setToolInputsVisibility(next)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "token-usage-visibility",
|
||||
label: () => {
|
||||
const visible = actions.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: actions.toggleUsageMetrics,
|
||||
},
|
||||
{
|
||||
id: "auto-cleanup-blank-sessions",
|
||||
label: () => {
|
||||
const enabled = actions.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: actions.toggleAutoCleanupBlankSessions,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function registerBehaviorCommands(register: (command: Command) => void, actions: BehaviorRegistryActions) {
|
||||
const commands = getBehaviorCommands(actions)
|
||||
commands.forEach((command) => register(command))
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import iconUrl from "../../images/CodeNomad-Icon.png"
|
||||
@@ -27,13 +29,6 @@ interface CliStatus {
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
}
|
||||
|
||||
function pickPhraseKey(previous?: PhraseKey) {
|
||||
const filtered = phraseKeys.filter((key) => key !== previous)
|
||||
const source = filtered.length > 0 ? filtered : phraseKeys
|
||||
@@ -46,17 +41,6 @@ function navigateTo(url?: string | null) {
|
||||
window.location.replace(url)
|
||||
}
|
||||
|
||||
function getTauriBridge(): TauriBridge | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null
|
||||
}
|
||||
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
|
||||
if (!bridge || !bridge.event || !bridge.invoke) {
|
||||
return null
|
||||
}
|
||||
return bridge
|
||||
}
|
||||
|
||||
function annotateDocument() {
|
||||
if (typeof document === "undefined") {
|
||||
return
|
||||
@@ -77,25 +61,22 @@ function LoadingApp() {
|
||||
setPhraseKey(pickPhraseKey())
|
||||
const unsubscribers: Array<() => void> = []
|
||||
|
||||
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
||||
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
||||
return
|
||||
}
|
||||
async function bootstrapTauri() {
|
||||
try {
|
||||
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
|
||||
const readyUnlisten = await listen("cli:ready", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
setError(null)
|
||||
setStatusKey(null)
|
||||
navigateTo(payload.url)
|
||||
})
|
||||
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
|
||||
const errorUnlisten = await listen("cli:error", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
if (payload.error) {
|
||||
setError(payload.error)
|
||||
setStatusKey("loadingScreen.status.issue")
|
||||
}
|
||||
})
|
||||
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
|
||||
const statusUnlisten = await listen("cli:status", (event) => {
|
||||
const payload = (event?.payload as CliStatus) || {}
|
||||
if (payload.state === "error" && payload.error) {
|
||||
setError(payload.error)
|
||||
@@ -109,7 +90,7 @@ function LoadingApp() {
|
||||
})
|
||||
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
|
||||
|
||||
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
|
||||
const result = await invoke<CliStatus>("cli_get_status")
|
||||
if (result?.state === "ready" && result.url) {
|
||||
navigateTo(result.url)
|
||||
} else if (result?.state === "error" && result.error) {
|
||||
@@ -123,7 +104,7 @@ function LoadingApp() {
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
void bootstrapTauri(getTauriBridge())
|
||||
void bootstrapTauri()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
|
||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||
import type { Session } from "../../types/session"
|
||||
import { messageStoreBus } from "./bus"
|
||||
import type { MessageStatus, SessionRevertState } from "./types"
|
||||
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
@@ -121,10 +121,10 @@ export function applyPartDeltaV2(
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void {
|
||||
if (!oldId || !newId || oldId === newId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.replaceMessageId({ oldId, newId })
|
||||
store.replaceMessageId({ oldId, newId, ...(options ?? {}) })
|
||||
}
|
||||
|
||||
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {
|
||||
|
||||
@@ -586,10 +586,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
||||
const cloned = clonePart(input.part)
|
||||
|
||||
|
||||
setState(
|
||||
"messages",
|
||||
input.messageId,
|
||||
@@ -792,6 +792,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
id: options.newId,
|
||||
isEphemeral: false,
|
||||
updatedAt: Date.now(),
|
||||
partIds: options.clearParts ? [] : existing.partIds,
|
||||
parts: options.clearParts ? {} : existing.parts,
|
||||
}
|
||||
|
||||
setState("messages", options.newId, cloned)
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface PartUpdateInput {
|
||||
export interface ReplaceMessageIdOptions {
|
||||
oldId: string
|
||||
newId: string
|
||||
clearParts?: boolean
|
||||
}
|
||||
|
||||
export interface ScrollCacheKey {
|
||||
|
||||
@@ -94,7 +94,7 @@ async function sendMessage(
|
||||
}
|
||||
|
||||
const messageId = createId("msg")
|
||||
const textPartId = createId("part")
|
||||
const textPartId = createId("prt")
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||
|
||||
@@ -110,7 +110,6 @@ async function sendMessage(
|
||||
|
||||
const requestParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
},
|
||||
@@ -120,9 +119,8 @@ async function sendMessage(
|
||||
for (const att of attachments) {
|
||||
const source = att.source
|
||||
if (source.type === "file") {
|
||||
const partId = createId("part")
|
||||
const partId = createId("prt")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
@@ -148,9 +146,8 @@ async function sendMessage(
|
||||
continue
|
||||
}
|
||||
|
||||
const partId = createId("part")
|
||||
const partId = createId("prt")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
})
|
||||
@@ -184,7 +181,6 @@ async function sendMessage(
|
||||
})
|
||||
|
||||
const requestBody = {
|
||||
messageID: messageId,
|
||||
parts: requestParts,
|
||||
...(session.agent && { agent: session.agent }),
|
||||
...(session.model.providerId &&
|
||||
|
||||
@@ -77,6 +77,29 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function isChildSession(instanceId: string, sessionId: string): boolean | null {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return null
|
||||
return session.parentId !== null && session.parentId !== undefined
|
||||
}
|
||||
|
||||
function shouldSendOsNotificationForSession(
|
||||
kind: "needsInput" | "idle",
|
||||
instanceId: string,
|
||||
sessionId: string | undefined | null,
|
||||
): boolean {
|
||||
if (!shouldSendOsNotification(kind)) return false
|
||||
if (!sessionId) return true
|
||||
|
||||
const child = isChildSession(instanceId, sessionId)
|
||||
|
||||
// Avoid notification spam from spawned child/subagent sessions arriving before hydration.
|
||||
if (child === null) return false
|
||||
if (child) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getInstanceDisplayName(instanceId: string): string {
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
@@ -240,19 +263,22 @@ function resolveMessageRole(info?: MessageInfo | null): MessageRole {
|
||||
return info?.role === "user" ? "user" : "assistant"
|
||||
}
|
||||
|
||||
function findPendingMessageId(
|
||||
function findPendingSyntheticMessageId(
|
||||
store: InstanceMessageStore,
|
||||
sessionId: string,
|
||||
role: MessageRole,
|
||||
): string | undefined {
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
const lastId = messageIds[messageIds.length - 1]
|
||||
if (!lastId) return undefined
|
||||
const record = store.getMessage(lastId)
|
||||
if (!record) return undefined
|
||||
if (record.sessionId !== sessionId) return undefined
|
||||
if (record.role !== role) return undefined
|
||||
return record.status === "sending" ? record.id : undefined
|
||||
for (const messageId of messageIds) {
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) continue
|
||||
if (record.sessionId !== sessionId) continue
|
||||
if (record.role !== role) continue
|
||||
if (record.status !== "sending") continue
|
||||
if (!record.isEphemeral) continue
|
||||
return record.id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
@@ -282,9 +308,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
@@ -345,9 +371,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
@@ -489,7 +515,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
if (shouldSendOsNotification("idle")) {
|
||||
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||
@@ -604,9 +630,10 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
|
||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||
fireOsNotification({ title, body })
|
||||
@@ -631,9 +658,10 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
||||
addQuestionToQueue(instanceId, request)
|
||||
upsertQuestionV2(instanceId, request)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
|
||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||
fireOsNotification({ title, body })
|
||||
|
||||
17
packages/ui/src/stores/settings-screen.ts
Normal file
17
packages/ui/src/stores/settings-screen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode"
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||
|
||||
export function openSettings(section: SettingsSectionId = "appearance") {
|
||||
setActiveSettingsSection(section)
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
|
||||
export function closeSettings() {
|
||||
setSettingsOpen(false)
|
||||
}
|
||||
|
||||
export { settingsOpen, activeSettingsSection, setActiveSettingsSection }
|
||||
39
packages/ui/src/styles/components/folder-drop.css
Normal file
39
packages/ui/src/styles/components/folder-drop.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.folder-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--folder-overlay-bg) 88%, var(--accent-primary) 12%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-drop-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: min(560px, 100%);
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--accent-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(in srgb, var(--surface-base) 92%, var(--accent-primary) 8%);
|
||||
box-shadow: var(--folder-card-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-drop-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.folder-drop-subtext {
|
||||
max-width: 32rem;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-relaxed);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -15,6 +15,9 @@
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-trigger:disabled,
|
||||
.selector-trigger[aria-disabled="true"],
|
||||
.selector-trigger[data-disabled],
|
||||
.selector-trigger-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
538
packages/ui/src/styles/components/settings-screen.css
Normal file
538
packages/ui/src/styles/components/settings-screen.css
Normal file
@@ -0,0 +1,538 @@
|
||||
.settings-screen-frame {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||
}
|
||||
|
||||
/* Override .modal-surface (defined later in panels.css). */
|
||||
.modal-surface.settings-screen-shell {
|
||||
width: min(1120px, 100%);
|
||||
height: min(88vh, 920px);
|
||||
max-height: none;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0;
|
||||
box-shadow: 0 32px 96px color-mix(in oklab, var(--overlay-scrim) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Settings UI uses square corners (no radius). */
|
||||
.modal-surface.settings-screen-shell .selector-trigger,
|
||||
.modal-surface.settings-screen-shell .selector-popover,
|
||||
.modal-surface.settings-screen-shell .selector-option,
|
||||
.modal-surface.settings-screen-shell .selector-button,
|
||||
.modal-surface.settings-screen-shell .selector-input,
|
||||
.modal-surface.settings-screen-shell .selector-search-input,
|
||||
.modal-surface.settings-screen-shell .remote-close,
|
||||
.modal-surface.settings-screen-shell .remote-section,
|
||||
.modal-surface.settings-screen-shell .remote-refresh,
|
||||
.modal-surface.settings-screen-shell .remote-toggle,
|
||||
.modal-surface.settings-screen-shell .remote-toggle-switch,
|
||||
.modal-surface.settings-screen-shell .remote-toggle-thumb,
|
||||
.modal-surface.settings-screen-shell .remote-address,
|
||||
.modal-surface.settings-screen-shell .remote-pill,
|
||||
.modal-surface.settings-screen-shell .remote-qr,
|
||||
.modal-surface.settings-screen-shell .remote-card,
|
||||
.modal-surface.settings-screen-shell .remote-error {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.settings-screen-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary));
|
||||
border-right: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.settings-screen-nav-header {
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 82%, transparent);
|
||||
}
|
||||
|
||||
.settings-screen-nav-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-screen-nav-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0;
|
||||
background: color-mix(in oklab, var(--accent-primary) 16%, var(--surface-base));
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.settings-screen-nav-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.settings-screen-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-screen-subtitle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-screen-nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-nav-button:hover {
|
||||
background: color-mix(in oklab, var(--surface-base) 70%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-nav-button[data-selected="true"] {
|
||||
background: color-mix(in oklab, var(--accent-primary) 14%, var(--surface-base));
|
||||
border-color: color-mix(in oklab, var(--accent-primary) 26%, var(--border-base));
|
||||
color: var(--text-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.settings-nav-button-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-screen-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top right, color-mix(in oklab, var(--accent-primary) 9%, transparent), transparent 28%),
|
||||
var(--surface-base);
|
||||
}
|
||||
|
||||
.settings-screen-content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
background: color-mix(in oklab, var(--surface-base) 92%, var(--surface-secondary) 8%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-screen-content-header-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-screen-content-eyebrow {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-screen-content-title {
|
||||
font-size: clamp(1.35rem, 2vw, 1.85rem);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.settings-screen-close {
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-screen-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings-section-stack,
|
||||
.settings-panel-body,
|
||||
.settings-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0;
|
||||
background: color-mix(in oklab, var(--surface-base) 86%, var(--surface-secondary) 14%);
|
||||
}
|
||||
|
||||
.settings-card-padless {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-card-content,
|
||||
.settings-card-header-padded {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings-card-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 65%, transparent);
|
||||
}
|
||||
|
||||
.settings-card-heading-with-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-card-heading-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.settings-card-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-card-subtitle {
|
||||
margin-top: 0.2rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-card-message {
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--border-base);
|
||||
border-radius: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-card-content {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0;
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.settings-help-text {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-password-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-form-group {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settings-pill-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.settings-pill-button:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
|
||||
}
|
||||
|
||||
.settings-pill-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-error-message {
|
||||
margin-top: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-critical, #e65c5c);
|
||||
background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent);
|
||||
border-radius: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-scope-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.6rem;
|
||||
border-radius: 0;
|
||||
background: color-mix(in oklab, var(--surface-secondary) 75%, var(--surface-base));
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-scope-badge-server {
|
||||
background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base));
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.settings-choice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
padding: 0.95rem;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-choice:hover {
|
||||
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.settings-choice:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-choice[data-selected="true"] {
|
||||
border-color: color-mix(in oklab, var(--accent-primary) 45%, var(--border-base));
|
||||
background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 20%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.settings-choice-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0;
|
||||
background: color-mix(in oklab, var(--surface-secondary) 76%, var(--surface-base));
|
||||
color: var(--accent-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-choice-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.settings-choice-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-choice-label {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.settings-choice-description {
|
||||
margin-top: 0.15rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-choice-check {
|
||||
margin-left: auto;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-choice[data-selected="true"] .settings-choice-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 0;
|
||||
border-top: 1px solid color-mix(in oklab, var(--border-base) 78%, transparent);
|
||||
}
|
||||
|
||||
.settings-toggle-row:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-toggle-row-compact {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-toggle-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-toggle-caption,
|
||||
.settings-inline-note {
|
||||
margin-top: 0.2rem;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-checkbox-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-checkbox-toggle input {
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.settings-toolbar-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.modal-surface.settings-screen-shell {
|
||||
min-height: min(760px, calc(100vh - 1rem));
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-screen-nav {
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.settings-screen-nav-list {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-nav-button {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-screen-frame {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-surface.settings-screen-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
min-height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.settings-screen-content-header,
|
||||
.settings-screen-scroll {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-card-header,
|
||||
.settings-toggle-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-toolbar-inline {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.settings-choice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "./components/buttons.css";
|
||||
@import "./components/badges.css";
|
||||
@import "./components/folder-drop.css";
|
||||
@import "./components/folder-loading.css";
|
||||
@import "./components/dropdown.css";
|
||||
@import "./components/selector.css";
|
||||
@@ -7,3 +8,4 @@
|
||||
@import "./components/directory-browser.css";
|
||||
@import "./components/remote-access.css";
|
||||
@import "./components/permission-notification.css";
|
||||
@import "./components/settings-screen.css";
|
||||
|
||||
@@ -1,39 +1,58 @@
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
.virtual-follow-list-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
|
||||
contain: layout paint style;
|
||||
}
|
||||
|
||||
.virtual-item-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-item-placeholder,
|
||||
.message-stream {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
|
||||
/* Scrolling optimizations */
|
||||
overscroll-behavior-y: contain;
|
||||
/* Prevents scroll chaining to parent elements */
|
||||
will-change: scroll-position;
|
||||
/* GPU acceleration hint for smoother scrolling */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Momentum scrolling on iOS */
|
||||
|
||||
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
|
||||
overflow-anchor: none;
|
||||
|
||||
/* Scrollbar styling */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.virtual-follow-list-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
/* Ensure it doesn't affect layout at all */
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.virtual-follow-list-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.virtual-follow-list-controls-container {
|
||||
position: absolute;
|
||||
bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
|
||||
right: var(--space-md);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.message-stream-placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.virtual-item-content {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.virtual-item-content-hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
color-scheme: light;
|
||||
/* Surface tokens */
|
||||
--surface-base: #ffffff;
|
||||
--surface-primary: var(--surface-base);
|
||||
--surface-secondary: #f5f5f5;
|
||||
--surface-muted: #f8fafc;
|
||||
--surface-code: #f1f5f9;
|
||||
@@ -178,6 +179,7 @@
|
||||
color-scheme: dark;
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-primary: var(--surface-base);
|
||||
--surface-secondary: #2a2a2a;
|
||||
--surface-muted: #212529;
|
||||
--surface-code: #1a1a1a;
|
||||
@@ -347,6 +349,7 @@
|
||||
color-scheme: dark;
|
||||
/* Surface tokens */
|
||||
--surface-base: #1a1a1a;
|
||||
--surface-primary: var(--surface-base);
|
||||
--surface-secondary: #2a2a2a;
|
||||
--surface-muted: #212529;
|
||||
--surface-code: #1a1a1a;
|
||||
|
||||
21
packages/ui/src/types/global.d.ts
vendored
21
packages/ui/src/types/global.d.ts
vendored
@@ -27,19 +27,30 @@ declare global {
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
restartCli?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||
getPathForFile?: (file: File) => string | null
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
|
||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
interface File {
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface FileSystemEntry {
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
}
|
||||
|
||||
interface DataTransferItem {
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null
|
||||
}
|
||||
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
dialog?: TauriDialogModule
|
||||
core?: {
|
||||
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
declare module "tauri-plugin-keepawake-api" {
|
||||
export interface KeepAwakeConfig {
|
||||
display?: boolean
|
||||
idle?: boolean
|
||||
sleep?: boolean
|
||||
}
|
||||
|
||||
export function start(config?: KeepAwakeConfig): Promise<void>
|
||||
export function stop(): Promise<void>
|
||||
}
|
||||
@@ -77,23 +77,23 @@ export default defineConfig({
|
||||
theme_color: "#1a1a1a",
|
||||
},
|
||||
workbox: {
|
||||
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
|
||||
// This is a build-time limit for the precache manifest, not a hard runtime cap.
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only precache static assets (avoid caching HTML documents / routes).
|
||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||
// Monaco assets can be large; cache them at runtime instead.
|
||||
globIgnores: [
|
||||
"**/*.html",
|
||||
"**/assets/*worker-*.js",
|
||||
"**/assets/editor.api-*.js",
|
||||
"**/monaco/vs/**/*",
|
||||
],
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
|
||||
// This is a build-time limit for the precache manifest, not a hard runtime cap.
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only precache static assets (avoid caching HTML documents / routes).
|
||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||
// Monaco assets can be large; cache them at runtime instead.
|
||||
globIgnores: [
|
||||
"**/*.html",
|
||||
"**/assets/*worker-*.js",
|
||||
"**/assets/editor.api-*.js",
|
||||
"**/monaco/vs/**/*",
|
||||
],
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, request }) => {
|
||||
if (url.pathname.startsWith("/api/")) return false
|
||||
if (request.destination === "document") return false
|
||||
|
||||
Reference in New Issue
Block a user