Compare commits
5 Commits
ready/ui-m
...
speech-inp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf07904789 | ||
|
|
4e576829b7 | ||
|
|
f9b5e2b529 | ||
|
|
cc2f6976f6 | ||
|
|
0ed19aeefb |
95
.github/workflows/build-and-upload.yml
vendored
95
.github/workflows/build-and-upload.yml
vendored
@@ -28,21 +28,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
upload_actions_artifacts:
|
|
||||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
actions_artifacts_retention_days:
|
|
||||||
description: "Retention (days) for GitHub Actions artifacts"
|
|
||||||
required: false
|
|
||||||
default: 7
|
|
||||||
type: number
|
|
||||||
actions_artifacts_name_prefix:
|
|
||||||
description: "Optional prefix for Actions artifact names"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
type: string
|
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -218,15 +203,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -268,15 +244,6 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -319,15 +286,6 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
|
||||||
path: packages/electron-app/release/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -381,7 +339,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -392,15 +350,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -465,7 +414,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -476,15 +425,6 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -552,7 +492,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -565,15 +505,6 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Windows)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
|
||||||
path: packages/tauri-app/release-tauri/*.zip
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -651,7 +582,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -677,15 +608,6 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Linux)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
|
||||||
path: packages/tauri-app/release-tauri/*
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -844,12 +766,3 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
|
||||||
if: ${{ inputs.upload_actions_artifacts }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
|
||||||
path: packages/electron-app/release/*.rpm
|
|
||||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
121
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -1,121 +0,0 @@
|
|||||||
name: Comment PR Artifacts
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
RETENTION_DAYS: 7
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Wait for PR build and comment
|
|
||||||
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const owner = context.repo.owner;
|
|
||||||
const repo = context.repo.repo;
|
|
||||||
const prNumber = Number(process.env.PR_NUMBER);
|
|
||||||
const headSha = process.env.HEAD_SHA;
|
|
||||||
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
|
||||||
const marker = '<!-- codenomad-pr-artifacts -->';
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
let matchedRun = null;
|
|
||||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
||||||
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
workflow_id: 'pr-build.yml',
|
|
||||||
event: 'pull_request',
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matchingRuns = runs
|
|
||||||
.filter((run) => run.head_sha === headSha)
|
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
|
||||||
|
|
||||||
matchedRun = matchingRuns[0] || null;
|
|
||||||
if (matchedRun && matchedRun.status === 'completed') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
|
||||||
await sleep(10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!matchedRun) {
|
|
||||||
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedRun.status !== 'completed') {
|
|
||||||
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const artifacts = await github.paginate(
|
|
||||||
github.rest.actions.listWorkflowRunArtifacts,
|
|
||||||
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
|
||||||
);
|
|
||||||
const active = artifacts.filter((artifact) => !artifact.expired);
|
|
||||||
|
|
||||||
const runUrl = matchedRun.html_url;
|
|
||||||
const artifactsBlock = active.length
|
|
||||||
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
|
||||||
: 'Artifacts: (none found on this run)';
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
marker,
|
|
||||||
'PR builds are available as GitHub Actions artifacts:',
|
|
||||||
'',
|
|
||||||
runUrl,
|
|
||||||
'',
|
|
||||||
`Artifacts expire in ${retentionDays} days.`,
|
|
||||||
artifactsBlock,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const created = await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: prNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
|
||||||
57
.github/workflows/pr-build.yml
vendored
57
.github/workflows/pr-build.yml
vendored
@@ -1,57 +0,0 @@
|
|||||||
name: PR Build Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- ready_for_review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-build-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
authorize:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
allowed: ${{ steps.auth.outputs.allowed }}
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check PR authorization
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "$BASE_REF" = "dev" ]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: authorize
|
|
||||||
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
upload: false
|
|
||||||
upload_actions_artifacts: true
|
|
||||||
actions_artifacts_retention_days: 7
|
|
||||||
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
|
||||||
set_versions: false
|
|
||||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
54
.github/workflows/restrict-non-dev-prs.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Restrict Non-Dev PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
restrict-non-dev-prs:
|
|
||||||
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
|
||||||
ACTOR: ${{ github.actor }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
steps:
|
|
||||||
- name: Check allowed actor
|
|
||||||
id: auth
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
normalized=",${ALLOWED_ACTORS},"
|
|
||||||
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
|
||||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Comment on unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
|
||||||
|
|
||||||
- name: Close unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
gh pr close "$PR_NUMBER"
|
|
||||||
|
|
||||||
- name: Fail unauthorized PR
|
|
||||||
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
|
||||||
run: |
|
|
||||||
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
|
||||||
exit 1
|
|
||||||
92
package-lock.json
generated
92
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -3253,9 +3253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.10.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3322,15 +3322,6 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-dialog": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -8240,6 +8231,27 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "6.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||||
|
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -10244,6 +10256,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/tauri-plugin-keepawake-api": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -10984,36 +11004,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/virtua": {
|
|
||||||
"version": "0.48.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
|
||||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.14.0",
|
|
||||||
"react-dom": ">=16.14.0",
|
|
||||||
"solid-js": ">=1.0",
|
|
||||||
"svelte": ">=5.0",
|
|
||||||
"vue": ">=3.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"solid-js": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"svelte": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"vue": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -12019,6 +12009,7 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -12033,7 +12024,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12070,7 +12061,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12080,6 +12071,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -12111,7 +12103,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12119,7 +12111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
@@ -12129,8 +12121,6 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
@@ -12143,7 +12133,7 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"virtua": "^0.48.8",
|
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -31,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.12.3",
|
"minServerVersion": "0.11.4",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.24"
|
"@opencode-ai/plugin": "1.2.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -46,4 +47,4 @@
|
|||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,36 @@ export interface BinaryValidationResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeechSegment {
|
||||||
|
startMs: number
|
||||||
|
endMs: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechCapabilitiesResponse {
|
||||||
|
available: boolean
|
||||||
|
configured: boolean
|
||||||
|
provider: string
|
||||||
|
supportsStt: boolean
|
||||||
|
supportsTts: boolean
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechTranscriptionResponse {
|
||||||
|
text: string
|
||||||
|
language?: string
|
||||||
|
durationMs?: number
|
||||||
|
segments?: SpeechSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechSynthesisResponse {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
|
|||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
import { SpeechService } from "./speech/service"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -388,6 +390,7 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -408,6 +411,7 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { registerStorageRoutes } from "./routes/storage"
|
|||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
import type { SpeechService } from "../speech/service"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -41,6 +43,7 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
speechService: SpeechService
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -252,6 +255,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|||||||
46
packages/server/src/server/routes/speech.ts
Normal file
46
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SpeechService } from "../../speech/service"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
speechService: SpeechService
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscribeBodySchema = z.object({
|
||||||
|
audioBase64: z.string().min(1, "Audio payload is required"),
|
||||||
|
mimeType: z.string().min(1, "Audio MIME type is required"),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SynthesizeBodySchema = z.object({
|
||||||
|
text: z.string().trim().min(1, "Text is required"),
|
||||||
|
format: z.enum(["mp3", "wav", "opus"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
||||||
|
|
||||||
|
app.post("/api/speech/transcribe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = TranscribeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.transcribe(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to transcribe audio")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to transcribe audio" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/speech/synthesize", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.synthesize(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to synthesize audio")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to synthesize audio" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
148
packages/server/src/speech/providers/openai-compatible.ts
Normal file
148
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import OpenAI from "openai"
|
||||||
|
import { toFile } from "openai/uploads"
|
||||||
|
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { NormalizedSpeechSettings, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
|
||||||
|
|
||||||
|
interface OpenAICompatibleSpeechProviderOptions {
|
||||||
|
settings: NormalizedSpeechSettings
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenAICompatibleSpeechProvider {
|
||||||
|
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
|
||||||
|
|
||||||
|
getCapabilities() {
|
||||||
|
const { settings } = this.options
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
configured: Boolean(settings.apiKey),
|
||||||
|
provider: settings.provider,
|
||||||
|
supportsStt: true,
|
||||||
|
supportsTts: true,
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
sttModel: settings.sttModel,
|
||||||
|
ttsModel: settings.ttsModel,
|
||||||
|
ttsVoice: settings.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
const client = this.createClient()
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const extension = extensionForMime(input.mimeType)
|
||||||
|
const buffer = Buffer.from(input.audioBase64, "base64")
|
||||||
|
const filename = input.filename?.trim() || `prompt-input.${extension}`
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
bytes: buffer.byteLength,
|
||||||
|
language: input.language,
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
},
|
||||||
|
"speech.transcribe",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestTranscription(client, buffer, filename, input)
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: typeof response?.text === "string" ? response.text : "",
|
||||||
|
language: typeof response?.language === "string" ? response.language : input.language,
|
||||||
|
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
|
||||||
|
segments: Array.isArray(response?.segments)
|
||||||
|
? response.segments
|
||||||
|
.filter((segment: any) => typeof segment?.text === "string")
|
||||||
|
.map((segment: any) => ({
|
||||||
|
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
|
||||||
|
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
|
||||||
|
text: String(segment.text),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestTranscription(
|
||||||
|
client: OpenAI,
|
||||||
|
buffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
input: TranscribeAudioInput,
|
||||||
|
): Promise<any> {
|
||||||
|
const baseRequest = {
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
...(input.language ? { language: input.language } : {}),
|
||||||
|
...(input.prompt ? { prompt: input.prompt } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file,
|
||||||
|
response_format: "verbose_json" as any,
|
||||||
|
} as any)) as any
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
|
||||||
|
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file: retryFile,
|
||||||
|
} as any)) as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
const client = this.createClient()
|
||||||
|
const format = input.format ?? "mp3"
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await client.audio.speech.create({
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice as any,
|
||||||
|
input: input.text,
|
||||||
|
response_format: format as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return {
|
||||||
|
audioBase64: audioBuffer.toString("base64"),
|
||||||
|
mimeType: mimeTypeForFormat(format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClient(): OpenAI {
|
||||||
|
const { settings } = this.options
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForMime(mimeType: string): string {
|
||||||
|
const normalized = mimeType.toLowerCase()
|
||||||
|
if (normalized.includes("webm")) return "webm"
|
||||||
|
if (normalized.includes("ogg")) return "ogg"
|
||||||
|
if (normalized.includes("wav")) return "wav"
|
||||||
|
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
|
||||||
|
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
|
||||||
|
return "webm"
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeTypeForFormat(format: "mp3" | "wav" | "opus"): string {
|
||||||
|
if (format === "wav") return "audio/wav"
|
||||||
|
if (format === "opus") return "audio/opus"
|
||||||
|
return "audio/mpeg"
|
||||||
|
}
|
||||||
91
packages/server/src/speech/service.ts
Normal file
91
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
|
||||||
|
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
|
||||||
|
|
||||||
|
const ServerSpeechSettingsSchema = z.object({
|
||||||
|
speech: z
|
||||||
|
.object({
|
||||||
|
provider: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
sttModel: z.string().optional(),
|
||||||
|
ttsModel: z.string().optional(),
|
||||||
|
ttsVoice: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface TranscribeAudioInput {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
filename?: string
|
||||||
|
language?: string
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeSpeechInput {
|
||||||
|
text: string
|
||||||
|
format?: "mp3" | "wav" | "opus"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechProvider {
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse
|
||||||
|
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||||
|
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSpeechSettings {
|
||||||
|
provider: string
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER = "openai-compatible"
|
||||||
|
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||||
|
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
|
||||||
|
const DEFAULT_TTS_VOICE = "alloy"
|
||||||
|
export class SpeechService {
|
||||||
|
constructor(
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse {
|
||||||
|
return this.createProvider().getCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
return this.createProvider().transcribe(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
return this.createProvider().synthesize(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProvider(): SpeechProvider {
|
||||||
|
const settings = this.resolveSettings()
|
||||||
|
return new OpenAICompatibleSpeechProvider({
|
||||||
|
settings,
|
||||||
|
logger: this.logger.child({ provider: settings.provider }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSettings(): NormalizedSpeechSettings {
|
||||||
|
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
||||||
|
const speech = parsed.speech ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
|
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
||||||
|
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
|
||||||
|
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||||
|
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||||
|
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2439
packages/tauri-app/Cargo.lock
generated
2439
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const serverDevInstallCommand =
|
|||||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -92,15 +91,6 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncServerUiBundle() {
|
|
||||||
console.log("[prebuild] syncing server public UI bundle...")
|
|
||||||
execSync(serverPrepareUiCommand, {
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
stdio: "inherit",
|
|
||||||
env: envWithRootBin,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
return
|
return
|
||||||
@@ -256,7 +246,6 @@ function copyUiLoadingAssets() {
|
|||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.12.3"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -19,12 +19,9 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
keepawake = "0.6"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
tauri-plugin-keepawake = "0.1.1"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
"opener:allow-open-url",
|
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,6 +2378,36 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:default",
|
||||||
|
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-start",
|
||||||
|
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:allow-stop",
|
||||||
|
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the start command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-start",
|
||||||
|
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the stop command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "keepawake:deny-stop",
|
||||||
|
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ use std::ffi::OsStr;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -19,24 +17,10 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn configure_spawn(command: &mut Command) {
|
|
||||||
command.creation_flags(CREATE_NO_WINDOW);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn configure_spawn(_command: &mut Command) {}
|
|
||||||
|
|
||||||
fn workspace_root() -> Option<PathBuf> {
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
std::env::current_dir().ok().and_then(|mut dir| {
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -52,46 +36,6 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
|||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn configure_posix_process_group(command: &mut Command) {
|
|
||||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
|
||||||
// processes (login shell/tsx) without leaving the server orphaned.
|
|
||||||
unsafe {
|
|
||||||
command.pre_exec(|| {
|
|
||||||
if libc::setpgid(0, 0) != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
|
||||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
|
||||||
if force {
|
|
||||||
args.push("/F".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut command = Command::new("taskkill");
|
|
||||||
command.args(&args);
|
|
||||||
configure_spawn(&mut command);
|
|
||||||
|
|
||||||
match command.output() {
|
|
||||||
Ok(output) => {
|
|
||||||
if output.status.success() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the PID is already gone, treat it as success.
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
|
||||||
let combined = format!("{stdout}\n{stderr}");
|
|
||||||
combined.contains("not found") || combined.contains("no running instance")
|
|
||||||
}
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
let mut display = url.to_string();
|
let mut display = url.to_string();
|
||||||
@@ -404,19 +348,11 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
// Prefer signaling the process group to avoid orphaning children
|
|
||||||
// when the CLI was launched via a wrapper shell.
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), false) {
|
let _ = child.kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -432,17 +368,11 @@ impl CliProcessManager {
|
|||||||
));
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
let _ = child.kill();
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -520,12 +450,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
ShellCommandType::Direct(cmd) => {
|
ShellCommandType::Direct(cmd) => {
|
||||||
@@ -535,12 +462,9 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
configure_spawn(&mut c);
|
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
#[cfg(unix)]
|
|
||||||
configure_posix_process_group(&mut c);
|
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -613,24 +537,7 @@ impl CliProcessManager {
|
|||||||
locked.error = Some("CLI did not start in time".to_string());
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
log_line("timeout waiting for CLI readiness");
|
log_line("timeout waiting for CLI readiness");
|
||||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
#[cfg(unix)]
|
let _ = child.kill();
|
||||||
unsafe {
|
|
||||||
let pid = child.id() as i32;
|
|
||||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
|
||||||
if group_res != 0 {
|
|
||||||
let _ = libc::kill(pid, libc::SIGKILL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
Self::emit_status(&app_clone, &locked);
|
Self::emit_status(&app_clone, &locked);
|
||||||
|
|||||||
@@ -3,11 +3,8 @@
|
|||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use keepawake::KeepAwake;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -15,31 +12,11 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::ffi::OsStr;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::iter;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
#[cfg(windows)]
|
|
||||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[derive(Clone)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
struct WakeLockConfig {
|
|
||||||
display: bool,
|
|
||||||
idle: bool,
|
|
||||||
sleep: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -58,39 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_start(
|
|
||||||
state: tauri::State<AppState>,
|
|
||||||
config: Option<WakeLockConfig>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let config = config.unwrap_or(WakeLockConfig {
|
|
||||||
display: true,
|
|
||||||
idle: false,
|
|
||||||
sleep: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut builder = keepawake::Builder::default();
|
|
||||||
builder
|
|
||||||
.display(config.display)
|
|
||||||
.idle(config.idle)
|
|
||||||
.sleep(config.sleep)
|
|
||||||
.reason("CodeNomad active session")
|
|
||||||
.app_name("CodeNomad")
|
|
||||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
|
||||||
|
|
||||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
*state_lock = Some(wake_lock);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
|
||||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
|
||||||
state_lock.take();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
}
|
}
|
||||||
@@ -157,22 +101,6 @@ fn emit_folder_drop_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn set_windows_app_user_model_id() {
|
|
||||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
|
||||||
.encode_wide()
|
|
||||||
.chain(iter::once(0))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
|
||||||
if result < 0 {
|
|
||||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn set_windows_app_user_model_id() {}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -181,14 +109,13 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_keepawake::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -200,12 +127,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||||
cli_get_status,
|
|
||||||
cli_restart,
|
|
||||||
wake_lock_start,
|
|
||||||
wake_lock_stop
|
|
||||||
])
|
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
// File menu
|
// File menu
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.12.3",
|
"version": "0.1.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.opencode.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.3",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -18,10 +18,8 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/api": "^2.10.1",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
@@ -32,7 +30,7 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"virtua": "^0.48.8",
|
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,4 +43,4 @@
|
|||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
|||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
|
import { useTheme } from "./lib/theme"
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
@@ -57,6 +59,7 @@ import { openSettings } from "./stores/settings-screen"
|
|||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
|
const { isDark } = useTheme()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
@@ -68,6 +71,7 @@ const App: Component = () => {
|
|||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -180,6 +184,10 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
@@ -353,6 +361,7 @@ const App: Component = () => {
|
|||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
import { escapeHtml } from "../lib/text-render-utils"
|
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
import { ErrorBoundary } from "solid-js"
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
import { getLanguageFromPath } from "../lib/markdown"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
@@ -135,4 +134,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -13,11 +13,8 @@ import { formatCompactCount } from "../lib/formatters"
|
|||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
import { openExternalUrl } from "../lib/external-url"
|
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
|
||||||
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
@@ -235,6 +232,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openExternalLink = (url: string) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -423,7 +425,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -431,13 +433,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.github")}
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitHubMarkIcon class="w-4 h-4" />
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
@@ -445,7 +447,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.githubStars")}
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Star class="w-4 h-4" />
|
<Star class="w-4 h-4" />
|
||||||
@@ -454,7 +456,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={DISCORD_URL}
|
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -462,7 +464,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.discord")}
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
openExternalLink(
|
||||||
|
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -115,23 +113,15 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
scopeKey={scopeKey()}
|
||||||
<div class="file-viewer-empty">
|
path={String(file().file || "")}
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
before={String((file() as any).before || "")}
|
||||||
</div>
|
after={String((file() as any).after || "")}
|
||||||
}
|
viewMode={props.diffViewMode()}
|
||||||
>
|
contextMode={props.diffContextMode()}
|
||||||
<LazyMonacoDiffViewer
|
wordWrap={props.diffWordWrapMode()}
|
||||||
scopeKey={scopeKey()}
|
/>
|
||||||
path={String(file().file || "")}
|
|
||||||
before={String((file() as any).before || "")}
|
|
||||||
after={String((file() as any).after || "")}
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
wordWrap={props.diffWordWrapMode()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +220,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
overlayAriaLabel="Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||||
|
|
||||||
const LazyMonacoFileViewer = lazy(() =>
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -53,8 +51,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
const emptyViewerMessage = () => {
|
||||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
return "Select a file to preview"
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
@@ -79,15 +77,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<Suspense
|
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
fallback={
|
|
||||||
<div class="file-viewer-empty">
|
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -101,7 +91,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +113,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.browserLoading() && entriesValue === null}>
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={sorted}>
|
<For each={sorted}>
|
||||||
@@ -164,7 +154,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.browserLoading()}>
|
<Show when={props.browserLoading()}>
|
||||||
<span>{props.t("instanceInfo.loading")}</span>
|
<span>Loading…</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +180,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
overlayAriaLabel="Files"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
const LazyMonacoDiffViewer = lazy(() =>
|
|
||||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -82,11 +80,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
if (!hasSession()) return "Select a session to view changes."
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
if (currentEntries === null) return "Loading git changes…"
|
||||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
if (nonDeleted().length === 0) return "No git changes yet."
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
return "No file selected."
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
@@ -124,14 +122,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<Suspense
|
<MonacoDiffViewer
|
||||||
fallback={
|
|
||||||
<div class="file-viewer-empty">
|
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LazyMonacoDiffViewer
|
|
||||||
scopeKey={props.scopeKey()}
|
scopeKey={props.scopeKey()}
|
||||||
path={String(file().path || "")}
|
path={String(file().path || "")}
|
||||||
before={String((file() as any).before || "")}
|
before={String((file() as any).before || "")}
|
||||||
@@ -140,8 +131,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
wordWrap={props.diffWordWrapMode()}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
)}
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -154,7 +144,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +169,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -210,7 +200,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -230,8 +220,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
@@ -274,7 +264,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
overlayAriaLabel="Git Changes"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -7,20 +8,6 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
type MarkdownModule = typeof import("../lib/markdown")
|
|
||||||
|
|
||||||
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
|
||||||
|
|
||||||
function loadMarkdownModule(): Promise<MarkdownModule> {
|
|
||||||
if (!markdownModulePromise) {
|
|
||||||
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
|
||||||
markdownModulePromise = null
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return markdownModulePromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -37,45 +24,6 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
|
||||||
if (partId) {
|
|
||||||
return partId
|
|
||||||
}
|
|
||||||
|
|
||||||
return `anonymous:${hashText(text)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHtmlEntitiesLocally(content: string): string {
|
|
||||||
if (!content.includes("&") || typeof document === "undefined") {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.innerHTML = content
|
|
||||||
return textarea.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(content: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
|
|
||||||
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFallbackHtml(content: string): string {
|
|
||||||
if (!content) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return escapeHtml(content).replace(/\n/g, "<br />")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -90,8 +38,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestKey = ""
|
let latestRequestedText = ""
|
||||||
let cleanupLanguageListener: (() => void) | undefined
|
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -100,14 +47,15 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntitiesLocally(rawText)
|
const text = decodeHtmlEntities(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
const cacheId = resolvePartCacheId(part, text)
|
if (!partId) {
|
||||||
|
throw new Error("Markdown rendering requires a part id")
|
||||||
|
}
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -115,46 +63,26 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
const { partId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
createEffect(async () => {
|
||||||
const cacheEntry: RenderCache = {
|
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||||
text: snapshot.text,
|
|
||||||
html: renderedHtml,
|
|
||||||
theme: snapshot.themeKey,
|
|
||||||
mode: snapshot.version,
|
|
||||||
}
|
|
||||||
setHtml(renderedHtml)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||||
const markdown = await loadMarkdownModule()
|
setMarkdownTheme(themeKey === "dark")
|
||||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
|
||||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
|
||||||
suppressHighlight: !snapshot.highlightEnabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
latestRequestedText = text
|
||||||
commitCacheEntry(snapshot, rendered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const snapshot = resolved()
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
return cache.theme === themeKey && cache.mode === version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = snapshot.part.renderCache
|
const localCache = part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -168,82 +96,111 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setHtml(renderFallbackHtml(snapshot.text))
|
const commitCacheEntry = (renderedHtml: string) => {
|
||||||
notifyRendered()
|
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
if (!highlightEnabled) {
|
||||||
log.error("Failed to render markdown:", error)
|
try {
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(rendered)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to render markdown:", error)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
commitCacheEntry(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (event: Event) => {
|
const handleClick = async (e: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (!copyButton) {
|
if (copyButton) {
|
||||||
return
|
e.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (code) {
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (copyText) {
|
||||||
|
if (success) {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (!code) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (!copyText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
let disposed = false
|
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||||
void loadMarkdownModule()
|
if (props.disableHighlight) {
|
||||||
.then((markdown) => {
|
return
|
||||||
if (disposed) {
|
}
|
||||||
return
|
|
||||||
|
const { part, text, themeKey, version } = resolved()
|
||||||
|
|
||||||
|
setMarkdownTheme(themeKey === "dark")
|
||||||
|
|
||||||
|
if (latestRequestedText !== text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||||
|
setHtml(rendered)
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
const snapshot = resolved()
|
}
|
||||||
if (!snapshot.highlightEnabled) {
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latestRequestKey = snapshot.requestKey
|
|
||||||
void renderSnapshot(snapshot).catch((error) => {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
log.error("Failed to load markdown module:", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
disposed = true
|
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener?.()
|
cleanupLanguageListener()
|
||||||
cleanupLanguageListener = undefined
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const proseClass = () => "markdown-body"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
class="markdown-body"
|
class={proseClass()}
|
||||||
data-view="markdown"
|
data-view="markdown"
|
||||||
data-part-id={resolved().partId}
|
data-part-id={resolved().partId}
|
||||||
data-markdown-theme={resolved().themeKey}
|
data-markdown-theme={resolved().themeKey}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||||
@@ -14,41 +13,13 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences } from "../stores/preferences"
|
import { preferences } from "../stores/preferences"
|
||||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||||
import type { Attachment } from "../types/attachment"
|
|
||||||
import { usePromptState } from "./prompt-input/usePromptState"
|
import { usePromptState } from "./prompt-input/usePromptState"
|
||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
|
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
|
||||||
if (!text || attachments.length === 0) return []
|
|
||||||
|
|
||||||
const usedCounters = new Set<string>()
|
|
||||||
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
|
||||||
const counter = match?.[1]
|
|
||||||
if (counter) usedCounters.add(counter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usedCounters.size === 0) return []
|
|
||||||
|
|
||||||
const consumed = new Set<string>()
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
if (!attachment?.id) continue
|
|
||||||
if (attachment?.source?.type !== "text") continue
|
|
||||||
const display = attachment.display
|
|
||||||
if (typeof display !== "string") continue
|
|
||||||
const match = display.match(pastedDisplayCounterRegex)
|
|
||||||
if (!match?.[1]) continue
|
|
||||||
if (usedCounters.has(match[1])) {
|
|
||||||
consumed.add(attachment.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(consumed)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
@@ -276,12 +247,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
commandName.length > 0 &&
|
commandName.length > 0 &&
|
||||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||||
|
|
||||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||||
const resolvedPrompt = isKnownSlashCommand
|
|
||||||
? resolvedCommandArgs
|
|
||||||
? `${commandToken} ${resolvedCommandArgs}`
|
|
||||||
: commandToken
|
|
||||||
: resolvePastedPlaceholders(text, currentAttachments)
|
|
||||||
const historyEntry = resolvedPrompt
|
const historyEntry = resolvedPrompt
|
||||||
|
|
||||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||||
@@ -297,10 +263,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
|
||||||
for (const attachmentId of consumedIds) {
|
|
||||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
|
||||||
}
|
|
||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
@@ -320,7 +282,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
} else if (isKnownSlashCommand) {
|
} else if (isKnownSlashCommand) {
|
||||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
@@ -450,9 +412,45 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowOverlay = () => prompt().length === 0
|
const shouldShowOverlay = () => prompt().length === 0
|
||||||
|
const voiceInput = usePromptVoiceInput({
|
||||||
|
prompt,
|
||||||
|
setPrompt,
|
||||||
|
getTextarea: () => textareaRef ?? null,
|
||||||
|
enabled: () => preferences().showPromptVoiceInput,
|
||||||
|
disabled: () => Boolean(props.disabled),
|
||||||
|
})
|
||||||
|
const showVoiceInput = () =>
|
||||||
|
preferences().showPromptVoiceInput &&
|
||||||
|
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
|
||||||
|
|
||||||
const instance = () => getActiveInstance()
|
const instance = () => getActiveInstance()
|
||||||
|
|
||||||
|
let voiceButtonPressed = false
|
||||||
|
|
||||||
|
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||||
|
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||||
|
voiceButtonPressed = true
|
||||||
|
|
||||||
|
if (event instanceof PointerEvent) {
|
||||||
|
const target = event.currentTarget
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
try {
|
||||||
|
target.setPointerCapture(event.pointerId)
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void voiceInput.startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
const endVoicePress = () => {
|
||||||
|
if (!voiceButtonPressed) return
|
||||||
|
voiceButtonPressed = false
|
||||||
|
voiceInput.stopRecording()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div
|
<div
|
||||||
@@ -594,6 +592,48 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
|
<Show when={showVoiceInput()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => endVoicePress()}
|
||||||
|
onLostPointerCapture={() => endVoicePress()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.repeat) return
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onBlur={() => endVoicePress()}
|
||||||
|
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||||
|
aria-label={voiceInput.buttonTitle()}
|
||||||
|
title={voiceInput.buttonTitle()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={voiceInput.isRecording()}
|
||||||
|
fallback={
|
||||||
|
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="stop-button"
|
class="stop-button"
|
||||||
@@ -628,3 +668,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVoiceTimer(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|||||||
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
|
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
interface UsePromptVoiceInputOptions {
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (value: string) => void
|
||||||
|
getTextarea: () => HTMLTextAreaElement | null
|
||||||
|
enabled: Accessor<boolean>
|
||||||
|
disabled: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceInputState = "idle" | "recording" | "transcribing"
|
||||||
|
|
||||||
|
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [state, setState] = createSignal<VoiceInputState>("idle")
|
||||||
|
const [elapsedMs, setElapsedMs] = createSignal(0)
|
||||||
|
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let mediaStream: MediaStream | null = null
|
||||||
|
let timerId: number | undefined
|
||||||
|
let shouldTranscribe = true
|
||||||
|
let recordedChunks: Blob[] = []
|
||||||
|
let recordingStartedAt = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void loadSpeechCapabilities()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cleanupMedia(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSupported = () => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUseVoiceInput = () => {
|
||||||
|
const capabilities = speechCapabilities()
|
||||||
|
return Boolean(
|
||||||
|
options.enabled() &&
|
||||||
|
isSupported() &&
|
||||||
|
capabilities?.available &&
|
||||||
|
capabilities?.configured &&
|
||||||
|
capabilities?.supportsStt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording(): Promise<void> {
|
||||||
|
if (state() === "recording") {
|
||||||
|
stopRecording()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!mediaRecorder || state() !== "recording") return
|
||||||
|
shouldTranscribe = true
|
||||||
|
mediaRecorder.stop()
|
||||||
|
setState("transcribing")
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRecording() {
|
||||||
|
if (!mediaRecorder || state() !== "recording") return
|
||||||
|
shouldTranscribe = false
|
||||||
|
mediaRecorder.stop()
|
||||||
|
cleanupMedia(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
|
||||||
|
|
||||||
|
if (!isSupported()) {
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recordedChunks = []
|
||||||
|
shouldTranscribe = true
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
mediaRecorder = createRecorder(mediaStream)
|
||||||
|
|
||||||
|
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
recordedChunks.push(event.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaRecorder.addEventListener("stop", () => {
|
||||||
|
void finalizeRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
recordingStartedAt = Date.now()
|
||||||
|
setElapsedMs(0)
|
||||||
|
setState("recording")
|
||||||
|
startTimer()
|
||||||
|
mediaRecorder.start()
|
||||||
|
} catch (error) {
|
||||||
|
cleanupMedia(false)
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeRecording() {
|
||||||
|
const recorder = mediaRecorder
|
||||||
|
const stream = mediaStream
|
||||||
|
mediaRecorder = null
|
||||||
|
mediaStream = null
|
||||||
|
|
||||||
|
if (!shouldTranscribe || recordedChunks.length === 0) {
|
||||||
|
recordedChunks = []
|
||||||
|
stopTracks(stream)
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBlob = new Blob(recordedChunks, { type: mimeType })
|
||||||
|
const transcription = await serverApi.transcribeAudio({
|
||||||
|
audioBase64: await blobToBase64(audioBlob),
|
||||||
|
mimeType,
|
||||||
|
})
|
||||||
|
if (transcription.text.trim()) {
|
||||||
|
insertTranscript(transcription.text.trim())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
recordedChunks = []
|
||||||
|
stopTracks(stream)
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTranscript(text: string) {
|
||||||
|
const current = options.prompt()
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const before = current.slice(0, start)
|
||||||
|
const after = current.slice(end)
|
||||||
|
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
|
||||||
|
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
|
||||||
|
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||||
|
const cursor = before.length + prefix.length + text.length
|
||||||
|
|
||||||
|
options.setPrompt(nextValue)
|
||||||
|
if (textarea) {
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupMedia(resetState = true) {
|
||||||
|
stopTimer()
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
mediaRecorder = null
|
||||||
|
stopTracks(mediaStream)
|
||||||
|
mediaStream = null
|
||||||
|
recordedChunks = []
|
||||||
|
if (resetState) {
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
stopTimer()
|
||||||
|
timerId = window.setInterval(() => {
|
||||||
|
setElapsedMs(Date.now() - recordingStartedAt)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timerId !== undefined) {
|
||||||
|
window.clearInterval(timerId)
|
||||||
|
timerId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
elapsedMs,
|
||||||
|
canUseVoiceInput,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
toggleRecording,
|
||||||
|
cancelRecording,
|
||||||
|
isRecording: () => state() === "recording",
|
||||||
|
isTranscribing: () => state() === "transcribing",
|
||||||
|
buttonTitle: () => {
|
||||||
|
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
|
||||||
|
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
|
||||||
|
return t("promptInput.voiceInput.start.title")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecorder(stream: MediaStream): MediaRecorder {
|
||||||
|
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
|
||||||
|
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
|
||||||
|
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTracks(stream: MediaStream | null) {
|
||||||
|
stream?.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ""
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
||||||
import { createMemo, For, type Component } from "solid-js"
|
import { createMemo, For, type Component } from "solid-js"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
|
|||||||
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
|
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
|
|||||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||||
|
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
|
|||||||
return <NotificationsSettingsSection />
|
return <NotificationsSettingsSection />
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteAccessSettingsSection />
|
return <RemoteAccessSettingsSection />
|
||||||
|
case "speech":
|
||||||
|
return <SpeechSettingsSection />
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return <OpenCodeSettingsSection />
|
return <OpenCodeSettingsSection />
|
||||||
case "appearance":
|
case "appearance":
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
toggleShowPromptVoiceInput,
|
||||||
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
|||||||
217
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
217
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { Mic, Volume2 } from "lucide-solid"
|
||||||
|
import { useConfig, type SpeechSettings } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
type DraftFields = {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDraftFields(speech: SpeechSettings): DraftFields {
|
||||||
|
return {
|
||||||
|
apiKey: speech.apiKey ?? "",
|
||||||
|
baseUrl: speech.baseUrl ?? "",
|
||||||
|
sttModel: speech.sttModel,
|
||||||
|
ttsModel: speech.ttsModel,
|
||||||
|
ttsVoice: speech.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
|
||||||
|
return a.apiKey === b.apiKey && a.baseUrl === b.baseUrl && a.sttModel === b.sttModel && a.ttsModel === b.ttsModel && a.ttsVoice === b.ttsVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpeechSettingsCard: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { serverSettings, updateSpeechSettings } = useConfig()
|
||||||
|
const initialDrafts = createDraftFields(serverSettings().speech)
|
||||||
|
const [isSaving, setIsSaving] = createSignal(false)
|
||||||
|
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
|
||||||
|
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const nextDrafts = createDraftFields(speech)
|
||||||
|
if (!isSaving() && !isDirty()) {
|
||||||
|
if (!isDraftEqual(drafts(), nextDrafts)) {
|
||||||
|
setDrafts(nextDrafts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void loadSpeechCapabilities()
|
||||||
|
})
|
||||||
|
|
||||||
|
const capabilityLabel = () => {
|
||||||
|
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
|
||||||
|
if (speechCapabilitiesError()) return t("settings.speech.status.error")
|
||||||
|
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDraft = (key: keyof DraftFields, value: string) => {
|
||||||
|
setSaveStatus("idle")
|
||||||
|
setDrafts((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = createMemo(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const current = drafts()
|
||||||
|
return (
|
||||||
|
(current.apiKey || "") !== (speech.apiKey || "") ||
|
||||||
|
(current.baseUrl || "") !== (speech.baseUrl || "") ||
|
||||||
|
current.sttModel !== speech.sttModel ||
|
||||||
|
current.ttsModel !== speech.ttsModel ||
|
||||||
|
current.ttsVoice !== speech.ttsVoice
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveStatusLabel = () => {
|
||||||
|
if (isSaving()) return t("settings.speech.save.saving")
|
||||||
|
if (saveStatus() === "saved") return t("settings.speech.save.saved")
|
||||||
|
if (saveStatus() === "error") return t("settings.speech.save.error")
|
||||||
|
return t("settings.speech.save.unsaved")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!isDirty() || isSaving()) return
|
||||||
|
const current = drafts()
|
||||||
|
setIsSaving(true)
|
||||||
|
setSaveStatus("idle")
|
||||||
|
try {
|
||||||
|
await updateSpeechSettings({
|
||||||
|
apiKey: current.apiKey.trim() || undefined,
|
||||||
|
baseUrl: current.baseUrl.trim() || undefined,
|
||||||
|
sttModel: current.sttModel.trim() || undefined,
|
||||||
|
ttsModel: current.ttsModel.trim() || undefined,
|
||||||
|
ttsVoice: current.ttsVoice.trim() || undefined,
|
||||||
|
})
|
||||||
|
await loadSpeechCapabilities(true)
|
||||||
|
setDrafts({
|
||||||
|
apiKey: current.apiKey.trim(),
|
||||||
|
baseUrl: current.baseUrl.trim(),
|
||||||
|
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
|
||||||
|
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
|
||||||
|
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
|
||||||
|
})
|
||||||
|
setSaveStatus("saved")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to save speech settings", error)
|
||||||
|
setSaveStatus("error")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Volume2 class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toolbar-inline">
|
||||||
|
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
|
||||||
|
<span class="settings-inline-note">{capabilityLabel()}</span>
|
||||||
|
<span class="settings-inline-note">{saveStatusLabel()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={!isDirty() || isSaving()}
|
||||||
|
>
|
||||||
|
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.apiKey.title")}
|
||||||
|
caption={t("settings.speech.apiKey.subtitle")}
|
||||||
|
value={drafts().apiKey}
|
||||||
|
onInput={(value) => updateDraft("apiKey", value)}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.baseUrl.title")}
|
||||||
|
caption={t("settings.speech.baseUrl.subtitle")}
|
||||||
|
value={drafts().baseUrl}
|
||||||
|
onInput={(value) => updateDraft("baseUrl", value)}
|
||||||
|
placeholder={t("settings.speech.baseUrl.placeholder")}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.sttModel.title")}
|
||||||
|
caption={t("settings.speech.sttModel.subtitle")}
|
||||||
|
value={drafts().sttModel}
|
||||||
|
onInput={(value) => updateDraft("sttModel", value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.ttsModel.title")}
|
||||||
|
caption={t("settings.speech.ttsModel.subtitle")}
|
||||||
|
value={drafts().ttsModel}
|
||||||
|
onInput={(value) => updateDraft("ttsModel", value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.ttsVoice.title")}
|
||||||
|
caption={t("settings.speech.ttsVoice.subtitle")}
|
||||||
|
value={drafts().ttsVoice}
|
||||||
|
onInput={(value) => updateDraft("ttsVoice", value)}
|
||||||
|
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="settings-inline-note">{t("settings.speech.help")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Field: Component<{
|
||||||
|
label: string
|
||||||
|
caption: string
|
||||||
|
value: string
|
||||||
|
type?: string
|
||||||
|
placeholder?: string
|
||||||
|
onInput: (value: string) => void
|
||||||
|
icon?: any
|
||||||
|
}> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
|
||||||
|
{props.icon}
|
||||||
|
<input
|
||||||
|
type={props.type ?? "text"}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||||
|
class="selector-input w-full"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeechSettingsCard
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import SpeechSettingsCard from "./speech-settings-card"
|
||||||
|
|
||||||
|
export const SpeechSettingsSection: Component = () => {
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<SpeechSettingsCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/text-render-utils"
|
import { escapeHtml } from "../../lib/markdown"
|
||||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|
||||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
|
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
|
||||||
const LazyToolCallDiffViewer = lazy(() =>
|
|
||||||
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
|
||||||
)
|
|
||||||
|
|
||||||
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
|
|
||||||
onMount(() => {
|
|
||||||
props.onRendered?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="tool-call-diff-viewer">
|
|
||||||
<div innerHTML={props.html} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CacheHandle = {
|
type CacheHandle = {
|
||||||
get<T>(): T | undefined
|
get<T>(): T | undefined
|
||||||
params(): unknown
|
params(): unknown
|
||||||
@@ -116,20 +101,15 @@ export function createDiffContentRenderer(params: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{cachedHtml ? (
|
<ToolCallDiffViewer
|
||||||
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
diffText={payload.diffText}
|
||||||
) : (
|
filePath={payload.filePath}
|
||||||
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
theme={themeKey}
|
||||||
<LazyToolCallDiffViewer
|
mode={diffMode()}
|
||||||
diffText={payload.diffText}
|
cachedHtml={cachedHtml}
|
||||||
filePath={payload.filePath}
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
theme={themeKey}
|
onRendered={handleDiffRendered}
|
||||||
mode={diffMode()}
|
/>
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
|
||||||
onRendered={handleDiffRendered}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||||
import { getLanguageFromPath } from "../../lib/text-render-utils"
|
import { getLanguageFromPath } from "../../lib/markdown"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
|
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -122,28 +122,55 @@ export interface VirtualFollowListProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VirtualFollowList<T>(props: 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 [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
|
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const bottomSentinel = () => bottomSentinelSignal()
|
||||||
|
|
||||||
const isActive = () => (props.isActive ? props.isActive() : true)
|
const isActive = () => (props.isActive ? props.isActive() : true)
|
||||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : 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 [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = 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 [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))
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
|
|
||||||
let userScrollIntentUntil = 0
|
let containerRef: HTMLDivElement | undefined
|
||||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
let shellRef: HTMLDivElement | undefined
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
let pendingScrollFrame: number | null = null
|
||||||
let lastResetKey: string | number | undefined
|
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 suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
let pendingInitialScroll = true
|
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 = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
@@ -154,7 +181,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
||||||
const now = performance.now()
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
if (direction) {
|
if (direction) {
|
||||||
lastUserScrollIntentDirection = direction
|
lastUserScrollIntentDirection = direction
|
||||||
@@ -162,7 +189,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasUserScrollIntent() {
|
function hasUserScrollIntent() {
|
||||||
return performance.now() <= userScrollIntentUntil
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
|
return now <= userScrollIntentUntil
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
@@ -203,189 +231,670 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScrollButtons() {
|
function updateScrollIndicatorsFromVisibility() {
|
||||||
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 hasItems = props.items().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !atBottom)
|
const bottomVisible = bottomSentinelVisible()
|
||||||
setShowScrollTopButton(hasItems && !atTop)
|
const topVisible = topSentinelVisible()
|
||||||
|
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||||
|
setShowScrollTopButton(hasItems && !topVisible)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
function clearScrollToBottomFrames() {
|
||||||
if (hasUserScrollIntent()) {
|
if (scrollToBottomFrame !== null) {
|
||||||
if (atBottom && !autoScroll()) {
|
cancelAnimationFrame(scrollToBottomFrame)
|
||||||
setAutoScroll(true)
|
scrollToBottomFrame = null
|
||||||
} else if (!atBottom && autoScroll()) {
|
}
|
||||||
setAutoScroll(false)
|
if (scrollToBottomDelayedFrame !== null) {
|
||||||
}
|
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||||
|
scrollToBottomDelayedFrame = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
const handle = virtuaHandle()
|
if (!containerRef) return
|
||||||
if (!handle) return
|
if (anchorLock()) {
|
||||||
if (options?.suppressAutoAnchor ?? !immediate) {
|
clearAnchorLock()
|
||||||
|
}
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
|
||||||
|
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||||
|
if (suppressAutoAnchor) {
|
||||||
suppressAutoScrollOnce = true
|
suppressAutoScrollOnce = true
|
||||||
}
|
}
|
||||||
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
|
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTop(immediate = true) {
|
function requestScrollToBottom(immediate = true) {
|
||||||
const handle = virtuaHandle()
|
if (!isActive()) {
|
||||||
if (!handle) return
|
pendingActiveScroll = true
|
||||||
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
|
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()
|
||||||
|
}
|
||||||
setAutoScroll(false)
|
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() {
|
function handleScroll() {
|
||||||
const isUserScroll = hasUserScrollIntent()
|
if (!containerRef) return
|
||||||
if (isUserScroll) {
|
if (pendingScrollFrame !== null) {
|
||||||
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
setAutoScroll(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
updateScrollButtons()
|
const isUserScroll = hasUserScrollIntent()
|
||||||
props.onScroll?.()
|
pendingScrollFrame = requestAnimationFrame(() => {
|
||||||
|
pendingScrollFrame = null
|
||||||
|
if (!containerRef) return
|
||||||
|
const previousScrollTop = lastKnownScrollTop
|
||||||
|
const currentScrollTop = containerRef.scrollTop
|
||||||
|
const deltaScrollTop = currentScrollTop - previousScrollTop
|
||||||
|
if (currentScrollTop !== lastKnownScrollTop) {
|
||||||
|
lastKnownScrollTop = currentScrollTop
|
||||||
|
}
|
||||||
|
const atBottom = bottomSentinelVisible()
|
||||||
|
|
||||||
// Find active key (roughly the first visible item)
|
const beforeAutoScroll = autoScroll()
|
||||||
const handle = virtuaHandle()
|
|
||||||
if (handle) {
|
const inferredDirection: "up" | "down" | null =
|
||||||
const start = handle.findItemIndex(handle.scrollOffset)
|
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
|
||||||
const items = props.items()
|
|
||||||
if (items[start]) {
|
// If the user scrolls manually, exit key-anchored mode.
|
||||||
const key = props.getKey(items[start], start)
|
if (isUserScroll && anchorLock()) {
|
||||||
if (key !== activeKey()) {
|
clearAnchorLock()
|
||||||
setActiveKey(key)
|
}
|
||||||
props.onActiveKeyChange?.(key)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const api: VirtualFollowListApi = {
|
||||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
|
||||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||||
scrollToKey: (key, opts) => {
|
scrollToKey: (key, opts) => {
|
||||||
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
|
if (typeof document === "undefined") return
|
||||||
if (index === -1) return
|
const anchorId = getAnchorId(key)
|
||||||
|
const behavior = opts?.behavior ?? "smooth"
|
||||||
|
const block = opts?.block ?? "start"
|
||||||
const nextAutoScroll = opts?.setAutoScroll ?? false
|
const nextAutoScroll = opts?.setAutoScroll ?? false
|
||||||
setAutoScroll(nextAutoScroll)
|
setAutoScroll(nextAutoScroll)
|
||||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
if (!nextAutoScroll) {
|
||||||
},
|
if (anchorLock()) {
|
||||||
notifyContentRendered: () => {
|
clearAnchorLock()
|
||||||
if (autoScroll()) {
|
}
|
||||||
scrollToBottom(true)
|
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 })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
notifyContentRendered: () => handleContentRendered(),
|
||||||
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
||||||
getAutoScroll: () => autoScroll(),
|
getAutoScroll: () => autoScroll(),
|
||||||
getScrollElement: () => scrollElement(),
|
getScrollElement: () => scrollElement(),
|
||||||
getShellElement: () => shellElement(),
|
getShellElement: () => shellElement(),
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => props.registerApi?.(api))
|
createEffect(() => {
|
||||||
createEffect(() => props.registerState?.(state))
|
props.registerApi?.(api)
|
||||||
|
})
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
createEffect(() => {
|
||||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
props.registerState?.(state)
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
})
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextKey = props.resetKey?.()
|
||||||
|
if (nextKey === undefined) return
|
||||||
|
if (lastResetKey === undefined) {
|
||||||
|
lastResetKey = nextKey
|
||||||
|
return
|
||||||
}
|
}
|
||||||
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
|
if (nextKey === lastResetKey) return
|
||||||
lastResetKey = nextKey
|
lastResetKey = nextKey
|
||||||
setAutoScroll(initialAutoScroll())
|
|
||||||
pendingInitialScroll = true
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Initial scroll and session activation
|
// 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
|
||||||
|
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
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = isActive()
|
const active = isActive()
|
||||||
if (!active) return
|
if (active) {
|
||||||
if (pendingInitialScroll && props.items().length > 0) {
|
resolvePendingActiveScroll()
|
||||||
pendingInitialScroll = false
|
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
|
||||||
if (initialScrollToBottom()) {
|
requestScrollToBottom(true)
|
||||||
scrollToBottom(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()
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||||
scrollToBottom(true)
|
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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
// Drop anchor lock if the anchored key is removed.
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
createEffect(() => {
|
||||||
setShellElement(shellElement)
|
const lock = anchorLock()
|
||||||
props.onShellElementChange?.(shellElement)
|
if (!lock) return
|
||||||
}}>
|
const keys = props.items().map((item, idx) => props.getKey(item, idx))
|
||||||
<div
|
if (!keys.includes(lock.key)) {
|
||||||
class="message-stream"
|
clearAnchorLock()
|
||||||
ref={el => {
|
}
|
||||||
setScrollElement(el)
|
})
|
||||||
props.onScrollElementChange?.(el)
|
|
||||||
attachScrollIntentListeners(el)
|
|
||||||
}}
|
|
||||||
onMouseUp={props.onMouseUp}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
<Show when={props.renderBeforeItems}>
|
|
||||||
{props.renderBeforeItems!()}
|
|
||||||
</Show>
|
|
||||||
<Virtualizer
|
|
||||||
ref={setVirtuaHandle}
|
|
||||||
scrollRef={scrollElement()}
|
|
||||||
data={props.items()}
|
|
||||||
bufferSize={props.overscanPx ?? 400}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
{(item, index) => props.renderItem(item, index())}
|
|
||||||
</Virtualizer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={props.renderOverlay}>
|
createEffect(() => {
|
||||||
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
|
if (props.items().length === 0) {
|
||||||
</Show>
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
|
setAutoScroll(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateScrollIndicatorsFromVisibility()
|
||||||
|
})
|
||||||
|
|
||||||
<Show when={props.renderControls}>
|
createEffect(() => {
|
||||||
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
|
const container = scrollElement()
|
||||||
</Show>
|
const topTarget = topSentinel()
|
||||||
|
const bottomTarget = bottomSentinel()
|
||||||
|
if (!container || !topTarget || !bottomTarget) return
|
||||||
|
if (typeof IntersectionObserver === "undefined") return
|
||||||
|
|
||||||
<Show
|
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
|
||||||
when={
|
|
||||||
!props.renderControls &&
|
const observer = new IntersectionObserver(
|
||||||
(showScrollTopButton() || showScrollBottomButton()) &&
|
(entries) => {
|
||||||
props.scrollToTopAriaLabel &&
|
let visibilityChanged = false
|
||||||
props.scrollToBottomAriaLabel
|
for (const entry of entries) {
|
||||||
|
if (entry.target === topTarget) {
|
||||||
|
setTopSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
} else if (entry.target === bottomTarget) {
|
||||||
|
setBottomSentinelVisible(entry.isIntersecting)
|
||||||
|
visibilityChanged = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
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">
|
<div class="message-scroll-button-wrapper">
|
||||||
<Show when={showScrollTopButton()}>
|
<Show when={showScrollTopButton()}>
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
↑
|
↑
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showScrollBottomButton()}>
|
<Show when={showScrollBottomButton()}>
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||||
|
aria-label={labelBottom}
|
||||||
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
↓
|
↓
|
||||||
</span>
|
</span>
|
||||||
@@ -393,6 +902,71 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
492
packages/ui/src/components/virtual-item.tsx
Normal file
492
packages/ui/src/components/virtual-item.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
|
SpeechCapabilitiesResponse,
|
||||||
|
SpeechSynthesisResponse,
|
||||||
|
SpeechTranscriptionResponse,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
@@ -235,6 +238,27 @@ export const serverApi = {
|
|||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
|
||||||
|
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
|
||||||
|
},
|
||||||
|
transcribeAudio(payload: {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
filename?: string
|
||||||
|
language?: string
|
||||||
|
prompt?: string
|
||||||
|
}): Promise<SpeechTranscriptionResponse> {
|
||||||
|
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" }): Promise<SpeechSynthesisResponse> {
|
||||||
|
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (path && path !== ".") {
|
if (path && path !== ".") {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { isTauriHost } from "./runtime-env"
|
|
||||||
|
|
||||||
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTauriHost()) {
|
|
||||||
try {
|
|
||||||
const { openUrl } = await import("@tauri-apps/plugin-opener")
|
|
||||||
await openUrl(url)
|
|
||||||
return
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[${context}] unable to open via system opener`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer")
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[${context}] unable to open external url`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { createLowlight, common } from "lowlight"
|
|
||||||
|
|
||||||
type AstNode = {
|
|
||||||
type: string
|
|
||||||
value?: string
|
|
||||||
children?: AstNode[]
|
|
||||||
startIndex?: number
|
|
||||||
endIndex?: number
|
|
||||||
lineNumber?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type SyntaxNodeEntry = {
|
|
||||||
node: AstNode
|
|
||||||
wrapper?: AstNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type SyntaxFileLine = {
|
|
||||||
value: string
|
|
||||||
lineNumber: number
|
|
||||||
valueLength: number
|
|
||||||
nodeList: SyntaxNodeEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowlightApi = ReturnType<typeof createLowlight>
|
|
||||||
|
|
||||||
export function processAST(ast: { children: AstNode[] }) {
|
|
||||||
let lineNumber = 1
|
|
||||||
const syntaxObj: Record<number, SyntaxFileLine> = {}
|
|
||||||
|
|
||||||
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
if (node.type === "text") {
|
|
||||||
const textValue = node.value ?? ""
|
|
||||||
if (!textValue.includes("\n")) {
|
|
||||||
const valueLength = textValue.length
|
|
||||||
if (!syntaxObj[lineNumber]) {
|
|
||||||
node.startIndex = 0
|
|
||||||
node.endIndex = valueLength - 1
|
|
||||||
syntaxObj[lineNumber] = {
|
|
||||||
value: textValue,
|
|
||||||
lineNumber,
|
|
||||||
valueLength,
|
|
||||||
nodeList: [{ node, wrapper }],
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
node.startIndex = syntaxObj[lineNumber].valueLength
|
|
||||||
node.endIndex = node.startIndex + valueLength - 1
|
|
||||||
syntaxObj[lineNumber].value += textValue
|
|
||||||
syntaxObj[lineNumber].valueLength += valueLength
|
|
||||||
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
|
|
||||||
}
|
|
||||||
node.lineNumber = lineNumber
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = textValue.split("\n")
|
|
||||||
node.children = node.children || []
|
|
||||||
for (let index = 0; index < lines.length; index++) {
|
|
||||||
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
|
|
||||||
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
|
|
||||||
const valueLength = value.length
|
|
||||||
const childNode: AstNode = {
|
|
||||||
type: "text",
|
|
||||||
value,
|
|
||||||
startIndex: Infinity,
|
|
||||||
endIndex: Infinity,
|
|
||||||
lineNumber: currentLineNumber,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!syntaxObj[currentLineNumber]) {
|
|
||||||
childNode.startIndex = 0
|
|
||||||
childNode.endIndex = valueLength - 1
|
|
||||||
syntaxObj[currentLineNumber] = {
|
|
||||||
value,
|
|
||||||
lineNumber: currentLineNumber,
|
|
||||||
valueLength,
|
|
||||||
nodeList: [{ node: childNode, wrapper }],
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
|
|
||||||
childNode.endIndex = childNode.startIndex + valueLength - 1
|
|
||||||
syntaxObj[currentLineNumber].value += value
|
|
||||||
syntaxObj[currentLineNumber].valueLength += valueLength
|
|
||||||
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
|
|
||||||
}
|
|
||||||
|
|
||||||
node.children.push(childNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
node.lineNumber = lineNumber
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
loopAST(node.children, node)
|
|
||||||
node.lineNumber = lineNumber
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
loopAST(ast.children)
|
|
||||||
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function _getAST() {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowlight = createLowlight(common)
|
|
||||||
|
|
||||||
lowlight.register("vue", function hljsDefineVue(hljs: any) {
|
|
||||||
return {
|
|
||||||
subLanguage: "xml",
|
|
||||||
contains: [
|
|
||||||
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
|
|
||||||
{
|
|
||||||
begin: /^(\s*)(<script>)/gm,
|
|
||||||
end: /^(\s*)(<\/script>)/gm,
|
|
||||||
subLanguage: "javascript",
|
|
||||||
excludeBegin: true,
|
|
||||||
excludeEnd: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
|
|
||||||
end: /^(\s*)(<\/script>)/gm,
|
|
||||||
subLanguage: "typescript",
|
|
||||||
excludeBegin: true,
|
|
||||||
excludeEnd: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
|
|
||||||
end: /^(\s*)(<\/style>)/gm,
|
|
||||||
subLanguage: "css",
|
|
||||||
excludeBegin: true,
|
|
||||||
excludeEnd: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
|
|
||||||
end: /^(\s*)(<\/style>)/gm,
|
|
||||||
subLanguage: "scss",
|
|
||||||
excludeBegin: true,
|
|
||||||
excludeEnd: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
|
|
||||||
end: /^(\s*)(<\/style>)/gm,
|
|
||||||
subLanguage: "stylus",
|
|
||||||
excludeBegin: true,
|
|
||||||
excludeEnd: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let maxLineToIgnoreSyntax = 2000
|
|
||||||
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
|
|
||||||
|
|
||||||
export const highlighter = {
|
|
||||||
name: "lowlight",
|
|
||||||
get maxLineToIgnoreSyntax() {
|
|
||||||
return maxLineToIgnoreSyntax
|
|
||||||
},
|
|
||||||
setMaxLineToIgnoreSyntax(value: number) {
|
|
||||||
maxLineToIgnoreSyntax = value
|
|
||||||
},
|
|
||||||
get ignoreSyntaxHighlightList() {
|
|
||||||
return ignoreSyntaxHighlightList
|
|
||||||
},
|
|
||||||
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
|
|
||||||
ignoreSyntaxHighlightList.length = 0
|
|
||||||
ignoreSyntaxHighlightList.push(...values)
|
|
||||||
},
|
|
||||||
getAST(raw: string, fileName?: string, lang?: string) {
|
|
||||||
const language = typeof lang === "string" ? lang.trim() : ""
|
|
||||||
if (
|
|
||||||
fileName &&
|
|
||||||
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
|
|
||||||
) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (language && lowlight.registered(language)) {
|
|
||||||
return lowlight.highlight(language, raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lowlight.highlightAuto(raw)
|
|
||||||
},
|
|
||||||
processAST(ast: { children: AstNode[] }) {
|
|
||||||
return processAST(ast)
|
|
||||||
},
|
|
||||||
hasRegisteredCurrentLang(lang: string) {
|
|
||||||
return lowlight.registered(lang)
|
|
||||||
},
|
|
||||||
getHighlighterEngine(): LowlightApi {
|
|
||||||
return lowlight
|
|
||||||
},
|
|
||||||
type: "class" as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const versions = "local-common"
|
|
||||||
@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
|
|||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
togglePromptSubmitOnEnter: () => void
|
togglePromptSubmitOnEnter: () => void
|
||||||
|
toggleShowPromptVoiceInput: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
toggleUsageMetrics: options.toggleUsageMetrics,
|
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode: options.setDiffViewMode,
|
setDiffViewMode: options.setDiffViewMode,
|
||||||
setToolOutputExpansion: options.setToolOutputExpansion,
|
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -114,10 +114,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
|
||||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
|
||||||
"instanceShell.gitChanges.deleted": "Deleted",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "File list",
|
"instanceShell.filesShell.fileListTitle": "File list",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||||
|
|||||||
@@ -138,4 +138,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Send message",
|
"promptInput.send.ariaLabel": "Send message",
|
||||||
"promptInput.send.errorFallback": "Failed to send message",
|
"promptInput.send.errorFallback": "Failed to send message",
|
||||||
"promptInput.send.errorTitle": "Send failed",
|
"promptInput.send.errorTitle": "Send failed",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
|
"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.title": "Auto-cleanup blank sessions",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "Enter to submit",
|
"settings.behavior.promptSubmit.title": "Enter to submit",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panel de estado",
|
"instanceShell.rightPanel.title": "Panel de estado",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||||
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
|
||||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||||
@@ -113,10 +112,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||||
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
|
||||||
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
|
||||||
"instanceShell.gitChanges.deleted": "Eliminado",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Enviar mensaje",
|
"promptInput.send.ariaLabel": "Enviar mensaje",
|
||||||
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
||||||
"promptInput.send.errorTitle": "Error al enviar",
|
"promptInput.send.errorTitle": "Error al enviar",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
|
"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.title": "Limpieza automatica de sesiones en blanco",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
|
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
|
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
||||||
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
|
||||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||||
@@ -113,10 +112,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
||||||
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
|
||||||
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
|
||||||
"instanceShell.gitChanges.deleted": "Supprimé",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Envoyer le message",
|
"promptInput.send.ariaLabel": "Envoyer le message",
|
||||||
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
||||||
"promptInput.send.errorTitle": "Échec de l'envoi",
|
"promptInput.send.errorTitle": "Échec de l'envoi",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
|
"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.title": "Nettoyage auto des sessions vides",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "ステータスパネル",
|
"instanceShell.rightPanel.title": "ステータスパネル",
|
||||||
"instanceShell.rightPanel.tabs.changes": "変更",
|
"instanceShell.rightPanel.tabs.changes": "変更",
|
||||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
|
|
||||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||||
@@ -113,10 +112,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
||||||
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
|
||||||
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
|
||||||
"instanceShell.gitChanges.deleted": "削除済み",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "メッセージを送信",
|
"promptInput.send.ariaLabel": "メッセージを送信",
|
||||||
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
||||||
"promptInput.send.errorTitle": "送信に失敗",
|
"promptInput.send.errorTitle": "送信に失敗",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
||||||
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
||||||
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "Enterで送信",
|
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Панель состояния",
|
"instanceShell.rightPanel.title": "Панель состояния",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
||||||
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
|
|
||||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||||
@@ -113,10 +112,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
||||||
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
|
||||||
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
|
||||||
"instanceShell.gitChanges.deleted": "Удалено",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Отправить сообщение",
|
"promptInput.send.ariaLabel": "Отправить сообщение",
|
||||||
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
||||||
"promptInput.send.errorTitle": "Не удалось отправить",
|
"promptInput.send.errorTitle": "Не удалось отправить",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
|
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
|
||||||
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
|
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "状态面板",
|
"instanceShell.rightPanel.title": "状态面板",
|
||||||
"instanceShell.rightPanel.tabs.changes": "更改",
|
"instanceShell.rightPanel.tabs.changes": "更改",
|
||||||
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
|
|
||||||
"instanceShell.rightPanel.tabs.files": "文件",
|
"instanceShell.rightPanel.tabs.files": "文件",
|
||||||
"instanceShell.rightPanel.tabs.status": "状态",
|
"instanceShell.rightPanel.tabs.status": "状态",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||||
@@ -113,10 +112,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
|
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
|
||||||
"instanceShell.sessionChanges.actions.show": "显示更改",
|
"instanceShell.sessionChanges.actions.show": "显示更改",
|
||||||
|
|
||||||
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
|
|
||||||
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
|
|
||||||
"instanceShell.gitChanges.deleted": "已删除",
|
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "文件列表",
|
"instanceShell.filesShell.fileListTitle": "文件列表",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",
|
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "发送消息",
|
"promptInput.send.ariaLabel": "发送消息",
|
||||||
"promptInput.send.errorFallback": "发送消息失败",
|
"promptInput.send.errorFallback": "发送消息失败",
|
||||||
"promptInput.send.errorTitle": "发送失败",
|
"promptInput.send.errorTitle": "发送失败",
|
||||||
|
"promptInput.voiceInput.start.title": "Start voice input",
|
||||||
|
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||||
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.speech": "Speech",
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -137,6 +138,34 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
|
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
|
||||||
"settings.behavior.autoCleanup.title": "自动清理空会话",
|
"settings.behavior.autoCleanup.title": "自动清理空会话",
|
||||||
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
||||||
|
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||||
|
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||||
"settings.behavior.promptSubmit.title": "回车发送",
|
"settings.behavior.promptSubmit.title": "回车发送",
|
||||||
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
||||||
|
"settings.speech.title": "Speech",
|
||||||
|
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||||
|
"settings.speech.provider.title": "Provider",
|
||||||
|
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||||
|
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||||
|
"settings.speech.status.loading": "Checking configuration...",
|
||||||
|
"settings.speech.status.configured": "Configured",
|
||||||
|
"settings.speech.status.missing": "Missing API key",
|
||||||
|
"settings.speech.status.error": "Speech service unavailable",
|
||||||
|
"settings.speech.apiKey.title": "API key",
|
||||||
|
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||||
|
"settings.speech.baseUrl.title": "Base URL",
|
||||||
|
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||||
|
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||||
|
"settings.speech.sttModel.title": "Transcription model",
|
||||||
|
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||||
|
"settings.speech.ttsModel.title": "Speech model",
|
||||||
|
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||||
|
"settings.speech.ttsVoice.title": "Default voice",
|
||||||
|
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||||
|
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"settings.speech.save.action": "Save",
|
||||||
|
"settings.speech.save.saving": "Saving...",
|
||||||
|
"settings.speech.save.saved": "Saved",
|
||||||
|
"settings.speech.save.unsaved": "Unsaved changes",
|
||||||
|
"settings.speech.save.error": "Save failed",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
|
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
import { tGlobal } from "./i18n"
|
import { tGlobal } from "./i18n"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
|
||||||
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -12,8 +11,43 @@ let currentTheme: "light" | "dark" = "light"
|
|||||||
let isInitialized = false
|
let isInitialized = false
|
||||||
let highlightSuppressed = false
|
let highlightSuppressed = false
|
||||||
let rendererSetup = false
|
let rendererSetup = false
|
||||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
|
||||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
const extensionToLanguage: Record<string, string> = {
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
py: "python",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
json: "json",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
md: "markdown",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
sql: "sql",
|
||||||
|
rs: "rust",
|
||||||
|
go: "go",
|
||||||
|
cpp: "cpp",
|
||||||
|
cc: "cpp",
|
||||||
|
cxx: "cpp",
|
||||||
|
hpp: "cpp",
|
||||||
|
h: "cpp",
|
||||||
|
c: "c",
|
||||||
|
java: "java",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
rb: "ruby",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageFromPath(path?: string | null): string | undefined {
|
||||||
|
if (!path) return undefined
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase()
|
||||||
|
return ext ? extensionToLanguage[ext] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
// Track loaded languages and queue for on-demand loading
|
// Track loaded languages and queue for on-demand loading
|
||||||
const loadedLanguages = new Set<string>()
|
const loadedLanguages = new Set<string>()
|
||||||
@@ -55,15 +89,10 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighterPromise
|
return highlighterPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
highlighterPromise = (async () => {
|
// Create highlighter with no preloaded languages
|
||||||
const shiki = await loadShikiModule()
|
highlighterPromise = createHighlighter({
|
||||||
return shiki.createHighlighter({
|
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
||||||
themes: ["github-light", "github-light-high-contrast", "github-dark"],
|
langs: [],
|
||||||
langs: [],
|
|
||||||
})
|
|
||||||
})().catch((error) => {
|
|
||||||
highlighterPromise = null
|
|
||||||
throw error
|
|
||||||
})
|
})
|
||||||
|
|
||||||
highlighter = await highlighterPromise
|
highlighter = await highlighterPromise
|
||||||
@@ -71,37 +100,12 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighter
|
return highlighter
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShikiModule() {
|
|
||||||
if (!shikiModulePromise) {
|
|
||||||
shikiModulePromise = import("shiki/bundle/full").then((module) => {
|
|
||||||
bundledLanguagesCache = module.bundledLanguages
|
|
||||||
return module
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return shikiModulePromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueHighlighterWarmup() {
|
|
||||||
if (highlighter || highlighterPromise) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void getOrCreateHighlighter().catch((error) => {
|
|
||||||
log.warn("Failed to initialize markdown highlighter", error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLanguageToken(token: string): string {
|
function normalizeLanguageToken(token: string): string {
|
||||||
return token.trim().toLowerCase()
|
return token.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
||||||
const normalized = normalizeLanguageToken(token)
|
const normalized = normalizeLanguageToken(token)
|
||||||
const bundledLanguages = bundledLanguagesCache
|
|
||||||
if (!bundledLanguages) {
|
|
||||||
return { canonical: null, raw: normalized }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a direct key match
|
// Check if it's a direct key match
|
||||||
if (normalized in bundledLanguages) {
|
if (normalized in bundledLanguages) {
|
||||||
@@ -144,43 +148,32 @@ async function ensureLanguages(content: string) {
|
|||||||
|
|
||||||
// Queue language loading tasks
|
// Queue language loading tasks
|
||||||
for (const token of foundLanguages) {
|
for (const token of foundLanguages) {
|
||||||
const rawToken = normalizeLanguageToken(token)
|
const { canonical, raw } = resolveLanguage(token)
|
||||||
if (!rawToken) {
|
const langKey = canonical || raw
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip "text" and aliases since Shiki handles plain text already
|
// Skip "text" and aliases since Shiki handles plain text already
|
||||||
if (rawToken === "text") {
|
if (langKey === "text" || raw === "text") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already loaded or queued
|
// Skip if already loaded or queued
|
||||||
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
|
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
queuedLanguages.add(rawToken)
|
queuedLanguages.add(langKey)
|
||||||
|
|
||||||
// Queue the language loading task
|
// Queue the language loading task
|
||||||
languageLoadQueue.push(async () => {
|
languageLoadQueue.push(async () => {
|
||||||
try {
|
try {
|
||||||
await loadShikiModule()
|
|
||||||
const { canonical, raw } = resolveLanguage(token)
|
|
||||||
const langKey = canonical || raw
|
|
||||||
|
|
||||||
if (langKey === "text" || raw === "text") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = await getOrCreateHighlighter()
|
const h = await getOrCreateHighlighter()
|
||||||
await h.loadLanguage(langKey as never)
|
await h.loadLanguage(langKey as never)
|
||||||
loadedLanguages.add(langKey)
|
loadedLanguages.add(langKey)
|
||||||
loadedLanguages.add(raw)
|
|
||||||
triggerLanguageListeners()
|
triggerLanguageListeners()
|
||||||
} catch {
|
} catch {
|
||||||
// Quietly ignore errors
|
// Quietly ignore errors
|
||||||
} finally {
|
} finally {
|
||||||
queuedLanguages.delete(rawToken)
|
queuedLanguages.delete(langKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -191,6 +184,52 @@ async function ensureLanguages(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decodeHtmlEntities(content: string): string {
|
||||||
|
if (!content.includes("&")) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
||||||
|
const namedEntities: Record<string, string> = {
|
||||||
|
amp: "&",
|
||||||
|
lt: "<",
|
||||||
|
gt: ">",
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
nbsp: " ",
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = content
|
||||||
|
let previous = ""
|
||||||
|
|
||||||
|
while (result.includes("&") && result !== previous) {
|
||||||
|
previous = result
|
||||||
|
result = result.replace(entityPattern, (match, entity) => {
|
||||||
|
if (!entity) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity[0] === "#") {
|
||||||
|
const isHex = entity[1]?.toLowerCase() === "x"
|
||||||
|
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
try {
|
||||||
|
return String.fromCodePoint(value)
|
||||||
|
} catch {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = namedEntities[entity.toLowerCase()]
|
||||||
|
return decoded !== undefined ? decoded : match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async function runLanguageLoadQueue() {
|
async function runLanguageLoadQueue() {
|
||||||
if (isQueueRunning || languageLoadQueue.length === 0) {
|
if (isQueueRunning || languageLoadQueue.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -210,6 +249,7 @@ async function runLanguageLoadQueue() {
|
|||||||
|
|
||||||
function setupRenderer(isDark: boolean) {
|
function setupRenderer(isDark: boolean) {
|
||||||
currentTheme = isDark ? "dark" : "light"
|
currentTheme = isDark ? "dark" : "light"
|
||||||
|
if (!highlighter) return
|
||||||
if (rendererSetup) return
|
if (rendererSetup) return
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
@@ -290,9 +330,8 @@ function setupRenderer(isDark: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function initMarkdown(isDark: boolean) {
|
export async function initMarkdown(isDark: boolean) {
|
||||||
setupRenderer(isDark)
|
|
||||||
queueHighlighterWarmup()
|
|
||||||
await getOrCreateHighlighter()
|
await getOrCreateHighlighter()
|
||||||
|
setupRenderer(isDark)
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,16 +350,15 @@ export async function renderMarkdown(
|
|||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
setupRenderer(currentTheme === "dark")
|
await initMarkdown(currentTheme === "dark")
|
||||||
isInitialized = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const suppressHighlight = options?.suppressHighlight ?? false
|
const suppressHighlight = options?.suppressHighlight ?? false
|
||||||
const decoded = decodeHtmlEntities(content)
|
const decoded = decodeHtmlEntities(content)
|
||||||
|
|
||||||
if (!suppressHighlight) {
|
if (!suppressHighlight) {
|
||||||
queueHighlighterWarmup()
|
// Queue language loading but don't wait for it to complete
|
||||||
void ensureLanguages(decoded)
|
await ensureLanguages(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousSuppressed = highlightSuppressed
|
const previousSuppressed = highlightSuppressed
|
||||||
@@ -337,3 +375,13 @@ export async function renderMarkdown(
|
|||||||
export async function getSharedHighlighter(): Promise<Highlighter> {
|
export async function getSharedHighlighter(): Promise<Highlighter> {
|
||||||
return getOrCreateHighlighter()
|
return getOrCreateHighlighter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
return text.replace(/[&<"']/g, (m) => map[m])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { runtimeEnv } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
@@ -16,8 +15,9 @@ export async function restartCli(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (runtimeEnv.host === "tauri") {
|
||||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
|
||||||
await invoke("cli_restart")
|
if (tauri?.invoke) {
|
||||||
|
await tauri.invoke("cli_restart")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { listen } from "@tauri-apps/api/event"
|
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
@@ -108,8 +107,13 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
|||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventApi = window.__TAURI__?.event
|
||||||
|
if (!eventApi?.listen) {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const unlisten = await listen("desktop:folder-drop", (event) => {
|
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
|
||||||
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||||
const paths = normalizePathList(payload.paths)
|
const paths = normalizePathList(payload.paths)
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0) {
|
||||||
@@ -130,10 +134,15 @@ export async function listenForNativeFolderDropState(onState: (state: NativeFold
|
|||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventApi = window.__TAURI__?.event
|
||||||
|
if (!eventApi?.listen) {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||||
listen("desktop:folder-drag-enter", () => onState("enter")),
|
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||||
listen("desktop:folder-drag-leave", () => onState("leave")),
|
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||||
])
|
])
|
||||||
return () => {
|
return () => {
|
||||||
unlistenEnter()
|
unlistenEnter()
|
||||||
|
|||||||
@@ -1,21 +1,43 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog"
|
|
||||||
import type { NativeDialogOptions } from "../native-functions"
|
import type { NativeDialogOptions } from "../native-functions"
|
||||||
import { getLogger } from "../../logger"
|
import { getLogger } from "../../logger"
|
||||||
const log = getLogger("actions")
|
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> {
|
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
|
||||||
|
const dialogApi = tauriBridge?.dialog
|
||||||
|
if (!dialogApi?.open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await open({
|
const response = await dialogApi.open({
|
||||||
title: options.title,
|
title: options.title,
|
||||||
defaultPath: options.defaultPath,
|
defaultPath: options.defaultPath,
|
||||||
directory: options.mode === "directory",
|
directory: options.mode === "directory",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: options.filters?.map((filter) => ({
|
filters: options.filters?.map((filter) => ({
|
||||||
name: filter.name ?? "Files",
|
name: filter.name,
|
||||||
extensions: filter.extensions,
|
extensions: filter.extensions,
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { runtimeEnv } from "../runtime-env"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
|
|
||||||
@@ -61,7 +60,8 @@ function hasAnyWakeLockSupport(): boolean {
|
|||||||
if (api?.setWakeLock) return true
|
if (api?.setWakeLock) return true
|
||||||
}
|
}
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (runtimeEnv.host === "tauri") {
|
||||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
// We'll attempt dynamic import; treat as potentially supported.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return Boolean((navigator as any)?.wakeLock?.request)
|
return Boolean((navigator as any)?.wakeLock?.request)
|
||||||
}
|
}
|
||||||
@@ -84,18 +84,21 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
|
|||||||
|
|
||||||
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (!hasAnyWakeLockSupport()) {
|
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) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// Match Electron's prevent-display-sleep behavior by keeping the display
|
// Plugin config supports toggling display/idle/sleep. Use a conservative
|
||||||
// awake without blocking explicit system sleep requests.
|
// default to keep both system + display awake.
|
||||||
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
|
await start({ display: true, idle: true, sleep: true })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
await invoke("wake_lock_stop")
|
await stop()
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.log("[wake-lock] tauri wake lock failed", error)
|
log.log("[wake-lock] tauri wake lock failed", error)
|
||||||
@@ -134,12 +137,13 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
|
|||||||
inFlight = (async () => {
|
inFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
const ok = await applyWakeLock(target)
|
const ok = await applyWakeLock(target)
|
||||||
applied = target ? ok : false
|
// Treat disable attempts as applied even if the underlying API doesn't exist.
|
||||||
|
applied = target
|
||||||
return ok
|
return ok
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = null
|
inFlight = null
|
||||||
// If desired changed while in-flight, re-apply once.
|
// If desired changed while in-flight, re-apply once.
|
||||||
if (desired !== target) {
|
if (desired !== applied) {
|
||||||
void setWakeLockDesired(desired)
|
void setWakeLockDesired(desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ export interface RuntimeEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface TauriCoreModule {
|
|
||||||
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI?: unknown
|
electronAPI?: unknown
|
||||||
__TAURI__?: {
|
__TAURI__?: {
|
||||||
core?: TauriCoreModule
|
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>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export type BehaviorRegistryActions = {
|
|||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
togglePromptSubmitOnEnter: () => void
|
togglePromptSubmitOnEnter: () => void
|
||||||
|
toggleShowPromptVoiceInput: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -248,6 +249,24 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.promptVoiceInput",
|
||||||
|
titleKey: "settings.behavior.promptVoiceInput.title",
|
||||||
|
subtitleKey: "settings.behavior.promptVoiceInput.subtitle",
|
||||||
|
get: (p) => Boolean(p.showPromptVoiceInput ?? true),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showPromptVoiceInput: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showPromptVoiceInput ?? true),
|
||||||
|
actions.toggleShowPromptVoiceInput,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
id: "behavior.promptSubmitOnEnter",
|
id: "behavior.promptSubmitOnEnter",
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
const extensionToLanguage: Record<string, string> = {
|
|
||||||
ts: "typescript",
|
|
||||||
tsx: "typescript",
|
|
||||||
js: "javascript",
|
|
||||||
jsx: "javascript",
|
|
||||||
py: "python",
|
|
||||||
sh: "bash",
|
|
||||||
bash: "bash",
|
|
||||||
json: "json",
|
|
||||||
html: "html",
|
|
||||||
css: "css",
|
|
||||||
md: "markdown",
|
|
||||||
yaml: "yaml",
|
|
||||||
yml: "yaml",
|
|
||||||
sql: "sql",
|
|
||||||
rs: "rust",
|
|
||||||
go: "go",
|
|
||||||
cpp: "cpp",
|
|
||||||
cc: "cpp",
|
|
||||||
cxx: "cpp",
|
|
||||||
hpp: "cpp",
|
|
||||||
h: "cpp",
|
|
||||||
c: "c",
|
|
||||||
java: "java",
|
|
||||||
cs: "csharp",
|
|
||||||
php: "php",
|
|
||||||
rb: "ruby",
|
|
||||||
swift: "swift",
|
|
||||||
kt: "kotlin",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLanguageFromPath(path?: string | null): string | undefined {
|
|
||||||
if (!path) return undefined
|
|
||||||
const ext = path.split(".").pop()?.toLowerCase()
|
|
||||||
return ext ? extensionToLanguage[ext] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeHtmlEntities(content: string): string {
|
|
||||||
if (!content.includes("&")) {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
|
|
||||||
const namedEntities: Record<string, string> = {
|
|
||||||
amp: "&",
|
|
||||||
lt: "<",
|
|
||||||
gt: ">",
|
|
||||||
quot: '"',
|
|
||||||
apos: "'",
|
|
||||||
nbsp: " ",
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = content
|
|
||||||
let previous = ""
|
|
||||||
|
|
||||||
while (result.includes("&") && result !== previous) {
|
|
||||||
previous = result
|
|
||||||
result = result.replace(entityPattern, (match, entity) => {
|
|
||||||
if (!entity) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity[0] === "#") {
|
|
||||||
const isHex = entity[1]?.toLowerCase() === "x"
|
|
||||||
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
|
|
||||||
if (!Number.isNaN(value)) {
|
|
||||||
try {
|
|
||||||
return String.fromCodePoint(value)
|
|
||||||
} catch {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoded = namedEntities[entity.toLowerCase()]
|
|
||||||
return decoded !== undefined ? decoded : match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(text: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
}
|
|
||||||
return text.replace(/[&<"']/g, (match) => map[match])
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
|
||||||
import { listen } from "@tauri-apps/api/event"
|
|
||||||
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import iconUrl from "../../images/CodeNomad-Icon.png"
|
import iconUrl from "../../images/CodeNomad-Icon.png"
|
||||||
@@ -29,6 +27,13 @@ interface CliStatus {
|
|||||||
error?: string | null
|
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) {
|
function pickPhraseKey(previous?: PhraseKey) {
|
||||||
const filtered = phraseKeys.filter((key) => key !== previous)
|
const filtered = phraseKeys.filter((key) => key !== previous)
|
||||||
const source = filtered.length > 0 ? filtered : phraseKeys
|
const source = filtered.length > 0 ? filtered : phraseKeys
|
||||||
@@ -41,6 +46,17 @@ function navigateTo(url?: string | null) {
|
|||||||
window.location.replace(url)
|
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() {
|
function annotateDocument() {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
return
|
return
|
||||||
@@ -61,22 +77,25 @@ function LoadingApp() {
|
|||||||
setPhraseKey(pickPhraseKey())
|
setPhraseKey(pickPhraseKey())
|
||||||
const unsubscribers: Array<() => void> = []
|
const unsubscribers: Array<() => void> = []
|
||||||
|
|
||||||
async function bootstrapTauri() {
|
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
||||||
|
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const readyUnlisten = await listen("cli:ready", (event) => {
|
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
|
||||||
const payload = (event?.payload as CliStatus) || {}
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
setError(null)
|
setError(null)
|
||||||
setStatusKey(null)
|
setStatusKey(null)
|
||||||
navigateTo(payload.url)
|
navigateTo(payload.url)
|
||||||
})
|
})
|
||||||
const errorUnlisten = await listen("cli:error", (event) => {
|
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
|
||||||
const payload = (event?.payload as CliStatus) || {}
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
setError(payload.error)
|
setError(payload.error)
|
||||||
setStatusKey("loadingScreen.status.issue")
|
setStatusKey("loadingScreen.status.issue")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const statusUnlisten = await listen("cli:status", (event) => {
|
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
|
||||||
const payload = (event?.payload as CliStatus) || {}
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
if (payload.state === "error" && payload.error) {
|
if (payload.state === "error" && payload.error) {
|
||||||
setError(payload.error)
|
setError(payload.error)
|
||||||
@@ -90,7 +109,7 @@ function LoadingApp() {
|
|||||||
})
|
})
|
||||||
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
|
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
|
||||||
|
|
||||||
const result = await invoke<CliStatus>("cli_get_status")
|
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
|
||||||
if (result?.state === "ready" && result.url) {
|
if (result?.state === "ready" && result.url) {
|
||||||
navigateTo(result.url)
|
navigateTo(result.url)
|
||||||
} else if (result?.state === "error" && result.error) {
|
} else if (result?.state === "error" && result.error) {
|
||||||
@@ -104,7 +123,7 @@ function LoadingApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTauriHost()) {
|
if (isTauriHost()) {
|
||||||
void bootstrapTauri()
|
void bootstrapTauri(getTauriBridge())
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
|
|||||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import { messageStoreBus } from "./bus"
|
import { messageStoreBus } from "./bus"
|
||||||
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types"
|
import type { MessageStatus, SessionRevertState } from "./types"
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -121,10 +121,10 @@ export function applyPartDeltaV2(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void {
|
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||||
if (!oldId || !newId || oldId === newId) return
|
if (!oldId || !newId || oldId === newId) return
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
store.replaceMessageId({ oldId, newId, ...(options ?? {}) })
|
store.replaceMessageId({ oldId, newId })
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {
|
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() })
|
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
||||||
const cloned = clonePart(input.part)
|
const cloned = clonePart(input.part)
|
||||||
|
|
||||||
setState(
|
setState(
|
||||||
"messages",
|
"messages",
|
||||||
input.messageId,
|
input.messageId,
|
||||||
@@ -792,8 +792,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
id: options.newId,
|
id: options.newId,
|
||||||
isEphemeral: false,
|
isEphemeral: false,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
partIds: options.clearParts ? [] : existing.partIds,
|
|
||||||
parts: options.clearParts ? {} : existing.parts,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState("messages", options.newId, cloned)
|
setState("messages", options.newId, cloned)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { decodeHtmlEntities } from "../../lib/text-render-utils"
|
import { decodeHtmlEntities } from "../../lib/markdown"
|
||||||
|
|
||||||
function decodeTextSegment(segment: any): any {
|
function decodeTextSegment(segment: any): any {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ export interface PartUpdateInput {
|
|||||||
export interface ReplaceMessageIdOptions {
|
export interface ReplaceMessageIdOptions {
|
||||||
oldId: string
|
oldId: string
|
||||||
newId: string
|
newId: string
|
||||||
clearParts?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScrollCacheKey {
|
export interface ScrollCacheKey {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
updateInstanceConfig as updateInstanceData,
|
updateInstanceConfig as updateInstanceData,
|
||||||
} from "./instance-config"
|
} from "./instance-config"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -27,6 +28,16 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type ExpansionPreference = "expanded" | "collapsed"
|
export type ExpansionPreference = "expanded" | "collapsed"
|
||||||
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
||||||
export type ListeningMode = "local" | "all"
|
export type ListeningMode = "local" | "all"
|
||||||
|
export type SpeechProviderPreference = "openai-compatible"
|
||||||
|
|
||||||
|
export interface SpeechSettings {
|
||||||
|
provider: SpeechProviderPreference
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
@@ -34,6 +45,7 @@ export interface UiSettings {
|
|||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
showTimelineTools: boolean
|
showTimelineTools: boolean
|
||||||
promptSubmitOnEnter: boolean
|
promptSubmitOnEnter: boolean
|
||||||
|
showPromptVoiceInput: boolean
|
||||||
locale?: string
|
locale?: string
|
||||||
diffViewMode: DiffViewMode
|
diffViewMode: DiffViewMode
|
||||||
toolOutputExpansion: ExpansionPreference
|
toolOutputExpansion: ExpansionPreference
|
||||||
@@ -75,6 +87,7 @@ interface ServerConfigBucket {
|
|||||||
listeningMode?: ListeningMode
|
listeningMode?: ListeningMode
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
opencodeBinary?: string
|
opencodeBinary?: string
|
||||||
|
speech?: Partial<SpeechSettings>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiStateBucket {
|
interface UiStateBucket {
|
||||||
@@ -107,6 +120,7 @@ const defaultUiSettings: UiSettings = {
|
|||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
showTimelineTools: true,
|
showTimelineTools: true,
|
||||||
promptSubmitOnEnter: false,
|
promptSubmitOnEnter: false,
|
||||||
|
showPromptVoiceInput: true,
|
||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
toolOutputExpansion: "expanded",
|
toolOutputExpansion: "expanded",
|
||||||
diagnosticsExpansion: "expanded",
|
diagnosticsExpansion: "expanded",
|
||||||
@@ -120,6 +134,13 @@ const defaultUiSettings: UiSettings = {
|
|||||||
notifyOnIdle: true,
|
notifyOnIdle: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultSpeechSettings: SpeechSettings = {
|
||||||
|
provider: "openai-compatible",
|
||||||
|
sttModel: "gpt-4o-mini-transcribe",
|
||||||
|
ttsModel: "gpt-4o-mini-tts",
|
||||||
|
ttsVoice: "alloy",
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
||||||
const sanitized = input ?? {}
|
const sanitized = input ?? {}
|
||||||
return {
|
return {
|
||||||
@@ -129,6 +150,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
||||||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||||
|
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||||
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
||||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
|
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
|
||||||
@@ -156,6 +178,27 @@ function normalizeRecord(value: unknown): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): SpeechSettings {
|
||||||
|
const sanitized = input ?? {}
|
||||||
|
return {
|
||||||
|
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
|
||||||
|
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
|
||||||
|
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
|
||||||
|
sttModel:
|
||||||
|
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
|
||||||
|
? sanitized.sttModel.trim()
|
||||||
|
: defaultSpeechSettings.sttModel,
|
||||||
|
ttsModel:
|
||||||
|
typeof sanitized.ttsModel === "string" && sanitized.ttsModel.trim()
|
||||||
|
? sanitized.ttsModel.trim()
|
||||||
|
: defaultSpeechSettings.ttsModel,
|
||||||
|
ttsVoice:
|
||||||
|
typeof sanitized.ttsVoice === "string" && sanitized.ttsVoice.trim()
|
||||||
|
? sanitized.ttsVoice.trim()
|
||||||
|
: defaultSpeechSettings.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
|
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const out: T[] = []
|
const out: T[] = []
|
||||||
@@ -206,12 +249,15 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerConfig(input?: ServerConfigBucket | null): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> {
|
function normalizeServerConfig(
|
||||||
|
input?: ServerConfigBucket | null,
|
||||||
|
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
|
||||||
const source = input ?? {}
|
const source = input ?? {}
|
||||||
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
||||||
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
||||||
const environmentVariables = normalizeRecord(source.environmentVariables)
|
const environmentVariables = normalizeRecord(source.environmentVariables)
|
||||||
return { listeningMode, opencodeBinary, environmentVariables }
|
const speech = normalizeSpeechSettings(source.speech)
|
||||||
|
return { listeningMode, opencodeBinary, environmentVariables, speech }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||||
@@ -342,6 +388,16 @@ function updateLastUsedBinary(path: string): void {
|
|||||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateSpeechSettings(updates: Partial<SpeechSettings>): Promise<void> {
|
||||||
|
const next = normalizeSpeechSettings({ ...serverSettings().speech, ...updates })
|
||||||
|
try {
|
||||||
|
await patchConfigOwner("server", { speech: next })
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to update speech settings", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addOpenCodeBinary(path: string, version?: string): void {
|
function addOpenCodeBinary(path: string, version?: string): void {
|
||||||
const nextList = buildBinaryList(path, version, opencodeBinaries())
|
const nextList = buildBinaryList(path, version, opencodeBinaries())
|
||||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
|
||||||
@@ -476,6 +532,10 @@ function togglePromptSubmitOnEnter(): void {
|
|||||||
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleShowPromptVoiceInput(): void {
|
||||||
|
updateUiSettings({ showPromptVoiceInput: !preferences().showPromptVoiceInput })
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAutoCleanupBlankSessions(): void {
|
function toggleAutoCleanupBlankSessions(): void {
|
||||||
const nextValue = !preferences().autoCleanupBlankSessions
|
const nextValue = !preferences().autoCleanupBlankSessions
|
||||||
log.info("toggle auto cleanup", { value: nextValue })
|
log.info("toggle auto cleanup", { value: nextValue })
|
||||||
@@ -521,6 +581,7 @@ interface ConfigContextValue {
|
|||||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||||
|
updateSpeechSettings: typeof updateSpeechSettings
|
||||||
|
|
||||||
// ui-owned state
|
// ui-owned state
|
||||||
recentFolders: typeof recentFolders
|
recentFolders: typeof recentFolders
|
||||||
@@ -544,6 +605,7 @@ interface ConfigContextValue {
|
|||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
||||||
|
toggleShowPromptVoiceInput: typeof toggleShowPromptVoiceInput
|
||||||
setDiffViewMode: typeof setDiffViewMode
|
setDiffViewMode: typeof setDiffViewMode
|
||||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||||
@@ -569,6 +631,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateSpeechSettings,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
uiState,
|
uiState,
|
||||||
@@ -588,6 +651,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -610,6 +674,8 @@ export const ConfigProvider: ParentComponent = (props) => {
|
|||||||
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
|
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
|
||||||
setServerConfigBucket(bucket as any)
|
setServerConfigBucket(bucket as any)
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
|
resetSpeechCapabilities()
|
||||||
|
void loadSpeechCapabilities(true)
|
||||||
})
|
})
|
||||||
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
|
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
|
||||||
setUiStateBucket(bucket as any)
|
setUiStateBucket(bucket as any)
|
||||||
@@ -648,6 +714,7 @@ export {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateSpeechSettings,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
removeRecentFolder,
|
removeRecentFolder,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
@@ -664,6 +731,7 @@ export {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async function sendMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageId = createId("msg")
|
const messageId = createId("msg")
|
||||||
const textPartId = createId("prt")
|
const textPartId = createId("part")
|
||||||
|
|
||||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ async function sendMessage(
|
|||||||
|
|
||||||
const requestParts: any[] = [
|
const requestParts: any[] = [
|
||||||
{
|
{
|
||||||
|
id: textPartId,
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: resolvedPrompt,
|
text: resolvedPrompt,
|
||||||
},
|
},
|
||||||
@@ -119,8 +120,9 @@ async function sendMessage(
|
|||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
const source = att.source
|
const source = att.source
|
||||||
if (source.type === "file") {
|
if (source.type === "file") {
|
||||||
const partId = createId("prt")
|
const partId = createId("part")
|
||||||
requestParts.push({
|
requestParts.push({
|
||||||
|
id: partId,
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
url: att.url,
|
url: att.url,
|
||||||
mime: source.mime,
|
mime: source.mime,
|
||||||
@@ -146,8 +148,9 @@ async function sendMessage(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const partId = createId("prt")
|
const partId = createId("part")
|
||||||
requestParts.push({
|
requestParts.push({
|
||||||
|
id: partId,
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: value,
|
text: value,
|
||||||
})
|
})
|
||||||
@@ -181,6 +184,7 @@ async function sendMessage(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
|
messageID: messageId,
|
||||||
parts: requestParts,
|
parts: requestParts,
|
||||||
...(session.agent && { agent: session.agent }),
|
...(session.agent && { agent: session.agent }),
|
||||||
...(session.model.providerId &&
|
...(session.model.providerId &&
|
||||||
|
|||||||
@@ -77,29 +77,6 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
|||||||
return false
|
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 {
|
function getInstanceDisplayName(instanceId: string): string {
|
||||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||||
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||||
@@ -263,22 +240,19 @@ function resolveMessageRole(info?: MessageInfo | null): MessageRole {
|
|||||||
return info?.role === "user" ? "user" : "assistant"
|
return info?.role === "user" ? "user" : "assistant"
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPendingSyntheticMessageId(
|
function findPendingMessageId(
|
||||||
store: InstanceMessageStore,
|
store: InstanceMessageStore,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
role: MessageRole,
|
role: MessageRole,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const messageIds = store.getSessionMessageIds(sessionId)
|
const messageIds = store.getSessionMessageIds(sessionId)
|
||||||
for (const messageId of messageIds) {
|
const lastId = messageIds[messageIds.length - 1]
|
||||||
const record = store.getMessage(messageId)
|
if (!lastId) return undefined
|
||||||
if (!record) continue
|
const record = store.getMessage(lastId)
|
||||||
if (record.sessionId !== sessionId) continue
|
if (!record) return undefined
|
||||||
if (record.role !== role) continue
|
if (record.sessionId !== sessionId) return undefined
|
||||||
if (record.status !== "sending") continue
|
if (record.role !== role) return undefined
|
||||||
if (!record.isEphemeral) continue
|
return record.status === "sending" ? record.id : undefined
|
||||||
return record.id
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||||
@@ -308,9 +282,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
let record = store.getMessage(messageId)
|
let record = store.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
|
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||||
if (pendingId && pendingId !== messageId) {
|
if (pendingId && pendingId !== messageId) {
|
||||||
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
|
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||||
record = store.getMessage(messageId)
|
record = store.getMessage(messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,9 +345,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
let record = store.getMessage(messageId)
|
let record = store.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const pendingId = findPendingSyntheticMessageId(store, sessionId, role)
|
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||||
if (pendingId && pendingId !== messageId) {
|
if (pendingId && pendingId !== messageId) {
|
||||||
replaceMessageIdV2(instanceId, pendingId, messageId, { clearParts: role === "user" })
|
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||||
record = store.getMessage(messageId)
|
record = store.getMessage(messageId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,7 +489,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
|||||||
const sessionId = event.properties?.sessionID
|
const sessionId = event.properties?.sessionID
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
|
if (shouldSendOsNotification("idle")) {
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||||
@@ -630,10 +604,9 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
|||||||
addPermissionToQueue(instanceId, permission)
|
addPermissionToQueue(instanceId, permission)
|
||||||
upsertPermissionV2(instanceId, permission)
|
upsertPermissionV2(instanceId, permission)
|
||||||
|
|
||||||
const sessionId = getPermissionSessionId(permission)
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||||
fireOsNotification({ title, body })
|
fireOsNotification({ title, body })
|
||||||
@@ -658,10 +631,9 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
|||||||
addQuestionToQueue(instanceId, request)
|
addQuestionToQueue(instanceId, request)
|
||||||
upsertQuestionV2(instanceId, request)
|
upsertQuestionV2(instanceId, request)
|
||||||
|
|
||||||
const sessionId = getQuestionSessionId(request)
|
if (shouldSendOsNotification("needsInput")) {
|
||||||
|
|
||||||
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
|
|
||||||
const title = getInstanceDisplayName(instanceId)
|
const title = getInstanceDisplayName(instanceId)
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
const label = getSessionTitle(instanceId, sessionId)
|
const label = getSessionTitle(instanceId, sessionId)
|
||||||
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||||
fireOsNotification({ title, body })
|
fireOsNotification({ title, body })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
|
|
||||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode"
|
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
|
||||||
|
|
||||||
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||||
|
|||||||
46
packages/ui/src/stores/speech.ts
Normal file
46
packages/ui/src/stores/speech.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
|
||||||
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("api")
|
||||||
|
|
||||||
|
const [speechCapabilities, setSpeechCapabilities] = createSignal<SpeechCapabilitiesResponse | null>(null)
|
||||||
|
const [speechCapabilitiesLoading, setSpeechCapabilitiesLoading] = createSignal(false)
|
||||||
|
const [speechCapabilitiesError, setSpeechCapabilitiesError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
let speechCapabilitiesPromise: Promise<SpeechCapabilitiesResponse | null> | null = null
|
||||||
|
|
||||||
|
async function loadSpeechCapabilities(force = false): Promise<SpeechCapabilitiesResponse | null> {
|
||||||
|
if (!force && speechCapabilities()) return speechCapabilities()
|
||||||
|
if (speechCapabilitiesPromise) return speechCapabilitiesPromise
|
||||||
|
|
||||||
|
setSpeechCapabilitiesLoading(true)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
speechCapabilitiesPromise = serverApi
|
||||||
|
.fetchSpeechCapabilities()
|
||||||
|
.then((result) => {
|
||||||
|
setSpeechCapabilities(result)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to load speech capabilities", error)
|
||||||
|
setSpeechCapabilities(null)
|
||||||
|
setSpeechCapabilitiesError(error instanceof Error ? error.message : String(error))
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSpeechCapabilitiesLoading(false)
|
||||||
|
speechCapabilitiesPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return speechCapabilitiesPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSpeechCapabilities(): void {
|
||||||
|
setSpeechCapabilities(null)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { speechCapabilities, speechCapabilitiesLoading, speechCapabilitiesError, loadSpeechCapabilities, resetSpeechCapabilities }
|
||||||
@@ -170,6 +170,41 @@
|
|||||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button {
|
||||||
|
@apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-secondary));
|
||||||
|
@apply scale-105;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:active:not(:disabled) {
|
||||||
|
@apply scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button.is-recording {
|
||||||
|
min-width: 3.5rem;
|
||||||
|
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
|
||||||
|
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:disabled {
|
||||||
|
@apply opacity-50 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-timer {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.stop-button:hover:not(:disabled) {
|
.stop-button:hover:not(:disabled) {
|
||||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||||
@apply opacity-95 scale-105;
|
@apply opacity-95 scale-105;
|
||||||
|
|||||||
@@ -1,58 +1,39 @@
|
|||||||
.virtual-follow-list-shell {
|
.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 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
gap: 0.0625rem;
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
contain: layout paint style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream {
|
.virtual-item-placeholder,
|
||||||
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 {
|
.message-stream-placeholder {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.virtual-item-content {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-item-content-hidden {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
11
packages/ui/src/types/global.d.ts
vendored
11
packages/ui/src/types/global.d.ts
vendored
@@ -47,9 +47,16 @@ declare global {
|
|||||||
webkitGetAsEntry?: () => FileSystemEntry | null
|
webkitGetAsEntry?: () => FileSystemEntry | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TauriDialogModule {
|
||||||
|
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||||
|
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
interface TauriBridge {
|
interface TauriBridge {
|
||||||
core?: {
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
dialog?: TauriDialogModule
|
||||||
|
event?: {
|
||||||
|
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal file
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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",
|
theme_color: "#1a1a1a",
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
|
// 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.
|
// This is a build-time limit for the precache manifest, not a hard runtime cap.
|
||||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||||
navigateFallback: null,
|
navigateFallback: null,
|
||||||
// Only precache static assets (avoid caching HTML documents / routes).
|
// Only precache static assets (avoid caching HTML documents / routes).
|
||||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
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.
|
// Monaco assets can be large; cache them at runtime instead.
|
||||||
globIgnores: [
|
globIgnores: [
|
||||||
"**/*.html",
|
"**/*.html",
|
||||||
"**/assets/*worker-*.js",
|
"**/assets/*worker-*.js",
|
||||||
"**/assets/editor.api-*.js",
|
"**/assets/editor.api-*.js",
|
||||||
"**/monaco/vs/**/*",
|
"**/monaco/vs/**/*",
|
||||||
],
|
],
|
||||||
// Only cache static UI assets; never cache API traffic.
|
// Only cache static UI assets; never cache API traffic.
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: ({ url, request }) => {
|
urlPattern: ({ url, request }) => {
|
||||||
if (url.pathname.startsWith("/api/")) return false
|
if (url.pathname.startsWith("/api/")) return false
|
||||||
if (request.destination === "document") return false
|
if (request.destination === "document") return false
|
||||||
@@ -134,34 +134,6 @@ export default defineConfig({
|
|||||||
main: resolve(__dirname, "./src/renderer/index.html"),
|
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||||
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
||||||
},
|
},
|
||||||
output: {
|
|
||||||
manualChunks(id) {
|
|
||||||
const normalizedId = id.replace(/\\/g, "/")
|
|
||||||
|
|
||||||
if (normalizedId.includes("/node_modules/@git-diff-view/")) {
|
|
||||||
return "git-diff-vendor"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedId.includes("/node_modules/highlight.js/") || normalizedId.includes("/node_modules/lowlight/")) {
|
|
||||||
return "highlight-vendor"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedId.includes("/node_modules/fast-diff/")) {
|
|
||||||
return "fast-diff-vendor"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedId.includes("/node_modules/monaco-editor/")) {
|
|
||||||
return "monaco-vendor"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalizedId.includes("/src/components/file-viewer/") ||
|
|
||||||
normalizedId.includes("/src/lib/monaco/")
|
|
||||||
) {
|
|
||||||
return "monaco-viewer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user