Compare commits
121 Commits
speech-inp
...
v0.13.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a4c3df16 | ||
|
|
10506920ac | ||
|
|
92c029d744 | ||
|
|
6eb3246d37 | ||
|
|
5c90de84de | ||
|
|
455a59f693 | ||
|
|
a89da02d6b | ||
|
|
69d9e95bee | ||
|
|
893d5f9296 | ||
|
|
e82e529a8f | ||
|
|
4f236ce36f | ||
|
|
2ffeb45a9c | ||
|
|
df16b64a95 | ||
|
|
f3c54df283 | ||
|
|
5658a9f62d | ||
|
|
9d6a5bcdc0 | ||
|
|
514b187b00 | ||
|
|
240acb7729 | ||
|
|
278b563c1a | ||
|
|
0af79002ed | ||
|
|
f3981a1cce | ||
|
|
031e8d5717 | ||
|
|
995fb3b6a3 | ||
|
|
aeb0ff11b3 | ||
|
|
b61cfbd9f9 | ||
|
|
481dd1a88a | ||
|
|
3f6cdd36f3 | ||
|
|
fe932c8307 | ||
|
|
64ac885157 | ||
|
|
1d953dfe64 | ||
|
|
42589464e5 | ||
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 | ||
|
|
55a6479c0e | ||
|
|
f88064af06 | ||
|
|
27bccb8d6b | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 | ||
|
|
0dc5867fb3 | ||
|
|
d13ecba322 | ||
|
|
740f37db86 | ||
|
|
d447b05821 | ||
|
|
1233121a13 | ||
|
|
a950d47df0 | ||
|
|
1c68f5d288 | ||
|
|
3bad0afd7d | ||
|
|
8567d49178 | ||
|
|
09284ee2ce | ||
|
|
a2e30f1b54 | ||
|
|
a4af811de3 | ||
|
|
c5aa59ca75 | ||
|
|
b8e0714b68 | ||
|
|
3f890e5de1 | ||
|
|
935926d875 | ||
|
|
74f753abf4 | ||
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
95
.github/workflows/build-and-upload.yml
vendored
95
.github/workflows/build-and-upload.yml
vendored
@@ -28,6 +28,21 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
upload_actions_artifacts:
|
||||
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
actions_artifacts_retention_days:
|
||||
description: "Retention (days) for GitHub Actions artifacts"
|
||||
required: false
|
||||
default: 7
|
||||
type: number
|
||||
actions_artifacts_name_prefix:
|
||||
description: "Optional prefix for Actions artifact names"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
@@ -203,6 +218,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
@@ -244,6 +268,15 @@ jobs:
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Electron Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
@@ -286,6 +319,15 @@ jobs:
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: packages/electron-app/release/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
@@ -339,7 +381,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -350,6 +392,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -414,7 +465,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
@@ -425,6 +476,15 @@ jobs:
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -492,7 +552,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
@@ -505,6 +565,15 @@ jobs:
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Windows)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||
path: packages/tauri-app/release-tauri/*.zip
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
@@ -582,7 +651,7 @@ jobs:
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
@@ -608,6 +677,15 @@ jobs:
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||
path: packages/tauri-app/release-tauri/*
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
@@ -766,3 +844,12 @@ jobs:
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||
path: packages/electron-app/release/*.rpm
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
122
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Comment PR Artifacts
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- 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 }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
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" == *",${PR_AUTHOR},"* ]]; 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}`);
|
||||
58
.github/workflows/pr-build.yml
vendored
Normal file
58
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: PR Build Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- 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 }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
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" == *",${PR_AUTHOR},"* ]]; then
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping builds for PR by unauthorized author 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
|
||||
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
55
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Restrict Non-Dev PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- 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 }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
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" == *",${PR_AUTHOR},"* ]]; 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 "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||
exit 1
|
||||
171
README.md
171
README.md
@@ -1,128 +1,127 @@
|
||||
# CodeNomad
|
||||
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
## The AI Coding Cockpit for OpenCode
|
||||
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
|
||||
|
||||
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
|
||||
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
---
|
||||
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
## Features
|
||||
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
- **🚀 Multi-Instance Workspace**
|
||||
- **🌐 Remote Access**
|
||||
- **🧠 Session Management**
|
||||
- **🎙️ Voice Input & Speech**
|
||||
- **🌳 Git Worktrees**
|
||||
- **💬 Rich Message Experience**
|
||||
- **⌨️ Command Palette**
|
||||
- **📁 File System Browser**
|
||||
- **🔐 Authentication & Security**
|
||||
- **🔔 Notifications**
|
||||
- **🎨 Theming**
|
||||
- **🌍 Internationalization**
|
||||
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
Choose the way that fits your workflow:
|
||||
### 🖥️ Desktop App
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
Available as both Electron and Tauri builds — choose based on your preference.
|
||||
|
||||
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Run**: Install and launch like any other app.
|
||||
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
### 🦀 Tauri App (Experimental)
|
||||
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
|
||||
|
||||
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
|
||||
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
|
||||
| Platform | Formats |
|
||||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
Run as a local server and access via browser. Perfect for remote development.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||
- [packages/server/README.md](packages/server/README.md)
|
||||
|
||||
To see all available options:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --help
|
||||
```
|
||||
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
|
||||
|
||||
### 🧪 Dev Releases
|
||||
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||
|
||||
Bleeding-edge builds from the `dev` branch:
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad-dev --launch
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
|
||||
- **Node.js 18+** — for server mode or building from source
|
||||
|
||||
## Troubleshooting
|
||||
---
|
||||
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
|
||||
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
|
||||
|
||||
Try running with one of these environment variables:
|
||||
|
||||
```bash
|
||||
# Most reliable workaround (can reduce rendering performance)
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
|
||||
# Alternative for some Wayland setups
|
||||
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
|
||||
```
|
||||
|
||||
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
|
||||
```
|
||||
|
||||
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
CodeNomad is a monorepo built with:
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
|
||||
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
|
||||
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
|
||||
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
```bash
|
||||
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
|
||||
cd CodeNomad
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
|
||||
|
||||
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
|
||||
|
||||
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
```
|
||||
|
||||
See full workaround in the original README.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||
|
||||
---
|
||||
|
||||
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 845 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 835 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 1.1 MiB |
159
package-lock.json
generated
159
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -64,7 +64,6 @@
|
||||
"version": "7.28.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -3253,9 +3252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3322,6 +3321,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -3372,7 +3380,6 @@
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -3474,7 +3481,6 @@
|
||||
"version": "22.19.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3549,7 +3555,6 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -3632,7 +3637,6 @@
|
||||
"version": "6.12.6",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -3835,6 +3839,7 @@
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -3852,6 +3857,7 @@
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -3872,6 +3878,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -3885,12 +3892,14 @@
|
||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -4204,6 +4213,7 @@
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -4267,7 +4277,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4758,6 +4767,7 @@
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -4887,6 +4897,7 @@
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -4898,6 +4909,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -5263,7 +5275,6 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -5430,6 +5441,7 @@
|
||||
"version": "24.13.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -5441,6 +5453,7 @@
|
||||
"version": "10.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -5454,6 +5467,7 @@
|
||||
"version": "6.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -5465,6 +5479,7 @@
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -6182,7 +6197,8 @@
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -7399,7 +7415,8 @@
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.6",
|
||||
@@ -7449,7 +7466,6 @@
|
||||
"version": "1.21.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -7581,6 +7597,7 @@
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -7592,6 +7609,7 @@
|
||||
"version": "2.3.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -7605,12 +7623,14 @@
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -7675,22 +7695,26 @@
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
"version": "4.7.0",
|
||||
@@ -7702,7 +7726,8 @@
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -8231,6 +8256,27 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -8485,7 +8531,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8633,7 +8678,8 @@
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "3.0.0",
|
||||
@@ -8882,6 +8928,7 @@
|
||||
"version": "3.6.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -8895,6 +8942,7 @@
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -9197,7 +9245,6 @@
|
||||
"version": "4.52.5",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -9421,7 +9468,6 @@
|
||||
"node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -9745,7 +9791,6 @@
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
@@ -9886,6 +9931,7 @@
|
||||
"version": "1.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -10219,6 +10265,7 @@
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -10235,14 +10282,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tauri-plugin-keepawake-api": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -10419,7 +10458,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10669,7 +10707,6 @@
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10983,11 +11020,40 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.48.8",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.14.0",
|
||||
"react-dom": ">=16.14.0",
|
||||
"solid-js": ">=1.0",
|
||||
"svelte": ">=5.0",
|
||||
"vue": ">=3.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -11472,7 +11538,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -11667,7 +11732,6 @@
|
||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -11956,6 +12020,7 @@
|
||||
"version": "4.1.1",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -11969,6 +12034,7 @@
|
||||
"version": "3.0.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -12002,7 +12068,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12039,7 +12105,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12049,6 +12115,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
@@ -12080,7 +12147,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12088,7 +12155,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12098,6 +12165,8 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
@@ -12110,7 +12179,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -22,7 +22,7 @@
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
"bumpVersion": "node ./scripts/bump-version.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.11.4",
|
||||
"minServerVersion": "0.13.3",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/version.json") {
|
||||
const response = await env.ASSETS.fetch(request)
|
||||
|
||||
const newHeaders = new Headers(response.headers)
|
||||
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
|
||||
newHeaders.set("Pragma", "no-cache")
|
||||
newHeaders.set("Expires", "0")
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
electron/resources/server/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -111,6 +112,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { enabled: false }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"media:requestMicrophoneAccess",
|
||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"remote:openWindow",
|
||||
async (
|
||||
_event,
|
||||
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
|
||||
): Promise<{ ok: boolean }> => {
|
||||
const opener = (mainWindow as BrowserWindow & {
|
||||
__codenomadOpenRemoteWindow?: (payload: {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<void>
|
||||
}).__codenomadOpenRemoteWindow
|
||||
if (!opener) {
|
||||
throw new Error("Remote window opening is not available")
|
||||
}
|
||||
await opener(payload)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { configureMediaPermissionHandlers } from "./permissions"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
@@ -20,6 +21,8 @@ let pendingCliUrl: string | null = null
|
||||
let pendingBootstrapToken: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
const remoteWindowOrigins = new Map<number, Set<string>>()
|
||||
const insecureWindowOrigins = new Map<number, Set<string>>()
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
@@ -92,8 +95,13 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
const origins = new Set<string>()
|
||||
if (window) {
|
||||
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
|
||||
origins.add(origin)
|
||||
}
|
||||
}
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
@@ -108,13 +116,13 @@ function getAllowedRendererOrigins(): string[] {
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
const allowedOrigins = getAllowedRendererOrigins(window)
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
@@ -127,7 +135,7 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
@@ -135,13 +143,54 @@ function setupNavigationGuards(window: BrowserWindow) {
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
if (shouldOpenExternally(url, window)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
remoteWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store allowed origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowAllowedOrigin(window: BrowserWindow) {
|
||||
remoteWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
insecureWindowOrigins.set(window.id, new Set([origin]))
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to store insecure origin", url, error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearWindowInsecureOrigin(window: BrowserWindow) {
|
||||
insecureWindowOrigins.delete(window.id)
|
||||
}
|
||||
|
||||
function isInsecureOriginAllowed(url: string) {
|
||||
try {
|
||||
const targetOrigin = new URL(url).origin
|
||||
for (const origins of insecureWindowOrigins.values()) {
|
||||
if (origins.has(targetOrigin)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -206,25 +255,30 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
const window = mainWindow
|
||||
|
||||
setupNavigationGuards(window)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
loadLoadingScreen(mainWindow)
|
||||
clearWindowAllowedOrigin(window)
|
||||
loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
createApplicationMenu(window)
|
||||
setupCliIPC(window, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
window.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
@@ -321,13 +375,68 @@ function finalizeCliSwap(url: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const window = mainWindow
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
setWindowAllowedOrigin(window, url)
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
function buildRemoteWindowTitle(name: string, baseUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(baseUrl)
|
||||
return `${name} - ${parsed.host}`
|
||||
} catch {
|
||||
return `${name} - ${baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
|
||||
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char))
|
||||
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
|
||||
}
|
||||
|
||||
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
|
||||
const targetUrl = new URL(payload.baseUrl)
|
||||
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
|
||||
const window = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#1a1a1a",
|
||||
icon: getIconPath(),
|
||||
title,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
setWindowAllowedOrigin(window, targetUrl.toString())
|
||||
if (payload.skipTlsVerify) {
|
||||
addWindowInsecureOrigin(window, targetUrl.toString())
|
||||
}
|
||||
|
||||
setupNavigationGuards(window)
|
||||
window.on("closed", () => {
|
||||
clearWindowAllowedOrigin(window)
|
||||
clearWindowInsecureOrigin(window)
|
||||
})
|
||||
|
||||
try {
|
||||
await window.loadURL(targetUrl.toString())
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -350,6 +459,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -380,14 +490,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@@ -489,6 +599,7 @@ app.whenReady().then(() => {
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
@@ -502,6 +613,17 @@ app.whenReady().then(() => {
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
event.preventDefault()
|
||||
console.warn("[cli] allowing insecure remote certificate for", url, error)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
callback(false)
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { session, systemPreferences } from "electron"
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||
if (!origin) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = new URL(origin).origin
|
||||
return allowedOrigins.includes(normalized)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||
if (permission !== "media") {
|
||||
return false
|
||||
}
|
||||
|
||||
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||
}
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||
if (!isAudioMediaRequest(permission, details)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||
})
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||
if (!isAudioMediaRequest(permission, details)) {
|
||||
callback(false)
|
||||
return
|
||||
}
|
||||
|
||||
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||
if (!isMac) {
|
||||
return true
|
||||
}
|
||||
|
||||
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||
if (status === "granted") {
|
||||
return true
|
||||
}
|
||||
|
||||
return systemPreferences.askForMediaAccess("microphone")
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = path.dirname(mainFilename)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -38,6 +42,9 @@ interface CliEntryResolution {
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
type ManagedChild = ChildProcess | UtilityProcess
|
||||
type ChildLaunchMode = "spawn" | "utility"
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
@@ -117,11 +124,13 @@ export declare interface CliProcessManager {
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private child?: ManagedChild
|
||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
@@ -132,36 +141,67 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
let child: ManagedChild
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} else {
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
this.childLaunchMode = "spawn"
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
@@ -176,23 +216,48 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
if (this.childLaunchMode === "utility") {
|
||||
const utilityChild = child as UtilityProcess
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
utilityChild.on("error", (error) => {
|
||||
const message = this.describeUtilityProcessError(error)
|
||||
console.error("[cli] utility supervisor failed:", error)
|
||||
this.updateStatus({ state: "error", error: message })
|
||||
this.emit("error", new Error(message))
|
||||
})
|
||||
|
||||
utilityChild.on("exit", (code) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
} else {
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
spawnedChild.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
spawnedChild.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -219,16 +284,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "utility") {
|
||||
return this.stopUtilityChild(child as UtilityProcess)
|
||||
}
|
||||
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
const pid = spawnedChild.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
@@ -304,7 +375,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
spawnedChild.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
@@ -324,10 +395,54 @@ export class CliProcessManager extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
child.once("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
if (child.pid === undefined) {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
child.kill()
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
@@ -335,14 +450,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
const pid = this.child.pid
|
||||
if (pid && process.platform !== "win32") {
|
||||
if (this.childLaunchMode === "utility") {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
} else if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
@@ -416,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
@@ -449,6 +572,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildExecutableCommand(command: string, args: string[]): string {
|
||||
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||
}
|
||||
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
@@ -519,4 +646,58 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||
}
|
||||
|
||||
private resolveShellNodeCommand(): string {
|
||||
const configured = process.env.NODE_BINARY?.trim()
|
||||
return configured && configured.length > 0 ? configured : "node"
|
||||
}
|
||||
|
||||
private resolveBundledProdEntry(): string {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||
}
|
||||
|
||||
private describeUtilityProcessError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const typed = error as { type?: unknown; location?: unknown }
|
||||
if (typeof typed.type === "string") {
|
||||
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ const electronAPI = {
|
||||
return null
|
||||
}
|
||||
},
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require("child_process")
|
||||
|
||||
const SHUTDOWN_GRACE_MS = 30_000
|
||||
|
||||
let child = null
|
||||
let shutdownTimer = null
|
||||
|
||||
function log(message, error) {
|
||||
if (error) {
|
||||
console.error(`[cli-supervisor] ${message}`, error)
|
||||
return
|
||||
}
|
||||
console.log(`[cli-supervisor] ${message}`)
|
||||
}
|
||||
|
||||
function clearShutdownTimer() {
|
||||
if (shutdownTimer) {
|
||||
clearTimeout(shutdownTimer)
|
||||
shutdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function forwardStream(stream, target) {
|
||||
if (!stream) return
|
||||
stream.on("data", (chunk) => {
|
||||
target.write(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
function terminateChild(force) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
function requestShutdown(force = false) {
|
||||
if (!child) {
|
||||
process.exit(force ? 1 : 0)
|
||||
return
|
||||
}
|
||||
|
||||
terminateChild(force)
|
||||
if (force) {
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
clearShutdownTimer()
|
||||
shutdownTimer = setTimeout(() => {
|
||||
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||
terminateChild(true)
|
||||
}, SHUTDOWN_GRACE_MS)
|
||||
shutdownTimer.unref()
|
||||
}
|
||||
|
||||
function installShutdownHandlers() {
|
||||
process.on("SIGTERM", () => requestShutdown(false))
|
||||
process.on("SIGINT", () => requestShutdown(false))
|
||||
process.on("disconnect", () => requestShutdown(false))
|
||||
process.on("uncaughtException", (error) => {
|
||||
log("uncaught exception", error)
|
||||
requestShutdown(true)
|
||||
})
|
||||
process.on("unhandledRejection", (error) => {
|
||||
log("unhandled rejection", error)
|
||||
requestShutdown(true)
|
||||
})
|
||||
}
|
||||
|
||||
function parsePayload() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Supervisor payload is required")
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Supervisor payload must be an object")
|
||||
}
|
||||
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||
throw new Error("Supervisor payload command is required")
|
||||
}
|
||||
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||
throw new Error("Supervisor payload args must be a string array")
|
||||
}
|
||||
|
||||
return {
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
installShutdownHandlers()
|
||||
|
||||
const payload = parsePayload()
|
||||
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||
|
||||
child = spawn(payload.command, payload.args, {
|
||||
cwd: payload.cwd,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
forwardStream(child.stdout, process.stdout)
|
||||
forwardStream(child.stderr, process.stderr)
|
||||
|
||||
child.on("error", (error) => {
|
||||
log("failed to spawn shell command", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearShutdownTimer()
|
||||
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||
process.exit()
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -20,6 +20,8 @@
|
||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"prepare:resources": "node scripts/prepare-resources.js",
|
||||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -33,8 +35,11 @@
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"prepackage:mac": "npm run prepare:resources",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"prepackage:win": "npm run prepare:resources",
|
||||
"package:win": "electron-builder --win",
|
||||
"prepackage:linux": "npm run prepare:resources",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -82,6 +87,12 @@
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||
},
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
|
||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs"
|
||||
import path, { join } from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
const serverRoot = join(appDir, "..", "server")
|
||||
const resourcesRoot = join(appDir, "electron", "resources")
|
||||
const serverDest = join(resourcesRoot, "server")
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
|
||||
function log(message) {
|
||||
console.log(`[prepare-resources] ${message}`)
|
||||
}
|
||||
|
||||
function ensureServerBuild() {
|
||||
const distPath = join(serverRoot, "dist")
|
||||
const publicPath = join(serverRoot, "public")
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(serverDepsMarker)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("installing production server dependencies")
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--workspaces=false",
|
||||
"--package-lock=false",
|
||||
"--install-strategy=shallow",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
]
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
npm_config_workspaces: "false",
|
||||
}
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of serverSources) {
|
||||
const from = join(serverRoot, name)
|
||||
const to = join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
throw new Error(`Missing required server artifact: ${from}`)
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
log(`copied ${name} to Electron resources`)
|
||||
}
|
||||
}
|
||||
|
||||
function stripNodeModuleBins() {
|
||||
const root = join(serverDest, "node_modules")
|
||||
if (!fs.existsSync(root)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
let removed = 0
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = join(current, entry.name)
|
||||
if (entry.name === ".bin") {
|
||||
fs.rmSync(full, { recursive: true, force: true })
|
||||
removed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
log(`removed ${removed} node_modules/.bin directories`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureServerDependencies()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[prepare-resources] failed:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -14,5 +14,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.14"
|
||||
"@opencode-ai/plugin": "1.3.7"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
let voiceModeEnabled = false
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "codenomad.voiceMode") {
|
||||
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
||||
if (!voiceModeEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModePrompt(): string {
|
||||
return [
|
||||
"Voice conversation mode is enabled.",
|
||||
"Prepend your reply with a fenced code block using language `spoken`.",
|
||||
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
|
||||
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
|
||||
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||
"Do not add generic phrases about whether the user should read more.",
|
||||
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
|
||||
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
|
||||
"After the `spoken` block, continue with your normal detailed response.",
|
||||
"Example:",
|
||||
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
|
||||
@@ -207,6 +207,69 @@ export interface BinaryValidationResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SpeechSegment {
|
||||
startMs: number
|
||||
endMs: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SpeechCapabilitiesResponse {
|
||||
available: boolean
|
||||
configured: boolean
|
||||
provider: string
|
||||
supportsStt: boolean
|
||||
supportsTts: boolean
|
||||
supportsStreamingTts: boolean
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
ttsFormats: string[]
|
||||
streamingTtsFormats: string[]
|
||||
}
|
||||
|
||||
export interface SpeechTranscriptionResponse {
|
||||
text: string
|
||||
language?: string
|
||||
durationMs?: number
|
||||
segments?: SpeechSegment[]
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisResponse {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VoiceModeStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProfile {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
skipTlsVerify: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastConnectedAt?: string
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProbeResponse {
|
||||
ok: boolean
|
||||
reachable: boolean
|
||||
normalizedUrl: string
|
||||
skipTlsVerify: boolean
|
||||
requiresAuth: boolean
|
||||
authenticated: boolean
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -139,6 +141,16 @@ export class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeCookieName(value: string | undefined): string {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
|
||||
128
packages/server/src/clients/connection-manager.ts
Normal file
128
packages/server/src/clients/connection-manager.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const STALE_CONNECTION_TIMEOUT_MS = 45000
|
||||
const STALE_SWEEP_INTERVAL_MS = 5000
|
||||
|
||||
export interface ClientConnectionRef {
|
||||
clientId: string
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export interface ClientConnectionRecord extends ClientConnectionRef {
|
||||
key: string
|
||||
connectedAt: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type ConnectionChangeEvent = {
|
||||
type: "connected" | "disconnected"
|
||||
connection: ClientConnectionRecord
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface RegisteredConnection extends ClientConnectionRecord {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export class ClientConnectionManager {
|
||||
private readonly connections = new Map<string, RegisteredConnection>()
|
||||
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
|
||||
private readonly sweepTimer: NodeJS.Timeout
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
|
||||
this.sweepTimer.unref?.()
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
clearInterval(this.sweepTimer)
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
this.disconnect(connection.key, "shutdown", false)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
|
||||
this.subscribers.add(listener)
|
||||
return () => this.subscribers.delete(listener)
|
||||
}
|
||||
|
||||
register(input: ClientConnectionRef & { close: () => void }): () => void {
|
||||
const key = getConnectionKey(input)
|
||||
const now = Date.now()
|
||||
const existing = this.connections.get(key)
|
||||
|
||||
if (existing) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
|
||||
this.disconnect(key, "replaced")
|
||||
}
|
||||
|
||||
const connection: RegisteredConnection = {
|
||||
key,
|
||||
clientId: input.clientId,
|
||||
connectionId: input.connectionId,
|
||||
connectedAt: now,
|
||||
lastSeenAt: now,
|
||||
close: input.close,
|
||||
}
|
||||
this.connections.set(key, connection)
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
|
||||
this.notify({ type: "connected", connection })
|
||||
return () => this.disconnect(key, "closed")
|
||||
}
|
||||
|
||||
pong(input: ClientConnectionRef): boolean {
|
||||
const key = getConnectionKey(input)
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) {
|
||||
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
|
||||
return false
|
||||
}
|
||||
|
||||
connection.lastSeenAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
isConnected(input: ClientConnectionRef): boolean {
|
||||
return this.connections.has(getConnectionKey(input))
|
||||
}
|
||||
|
||||
private sweepStaleConnections(): void {
|
||||
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
|
||||
for (const connection of Array.from(this.connections.values())) {
|
||||
if (connection.lastSeenAt > cutoff) continue
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
|
||||
this.disconnect(connection.key, "timeout")
|
||||
}
|
||||
}
|
||||
|
||||
private disconnect(key: string, reason: string, invokeClose = true): void {
|
||||
const connection = this.connections.get(key)
|
||||
if (!connection) return
|
||||
this.connections.delete(key)
|
||||
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
|
||||
|
||||
if (invokeClose) {
|
||||
try {
|
||||
connection.close()
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
|
||||
}
|
||||
}
|
||||
|
||||
this.notify({ type: "disconnected", connection, reason })
|
||||
}
|
||||
|
||||
private notify(event: ConnectionChangeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
try {
|
||||
subscriber(event)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(input: ClientConnectionRef): string {
|
||||
return `${input.clientId}:${input.connectionId}`
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const PreferencesSchema = z
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
|
||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
writeFile(relativePath: string, contents: string): void {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("writeFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
fs.writeFileSync(resolved, contents, "utf-8")
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
|
||||
@@ -19,10 +19,11 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -54,6 +55,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -99,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -138,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -184,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -265,6 +274,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -304,6 +314,7 @@ async function main() {
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -388,6 +399,7 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
@@ -408,6 +420,7 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
@@ -438,18 +451,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -460,7 +477,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -468,6 +487,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
const additionalRemoteUrls = serverMeta.addresses
|
||||
.map((addr) => addr.remoteUrl)
|
||||
.filter((url) => url !== serverMeta.remoteUrl)
|
||||
|
||||
if (additionalRemoteUrls.length > 0) {
|
||||
console.log("Other Accessible URLs:")
|
||||
for (const url of additionalRemoteUrls) {
|
||||
console.log(` - ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
|
||||
96
packages/server/src/plugins/voice-mode.ts
Normal file
96
packages/server/src/plugins/voice-mode.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
|
||||
import type { PluginChannelManager } from "./channel"
|
||||
|
||||
interface VoiceModeManagerOptions {
|
||||
connections: ClientConnectionManager
|
||||
channel: PluginChannelManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export class VoiceModeManager {
|
||||
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
|
||||
private readonly aggregateByInstance = new Map<string, boolean>()
|
||||
|
||||
constructor(private readonly options: VoiceModeManagerOptions) {
|
||||
this.options.connections.subscribe((event) => {
|
||||
if (event.type !== "disconnected") return
|
||||
this.clearConnection(event.connection)
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||
this.options.logger.debug(
|
||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||
"Ignoring voice mode enable for disconnected client connection",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const key = getConnectionKey(connection)
|
||||
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
|
||||
|
||||
if (enabled) {
|
||||
current.add(key)
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
} else if (current.delete(key)) {
|
||||
if (current.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
} else {
|
||||
this.enabledConnectionsByInstance.set(instanceId, current)
|
||||
}
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
|
||||
syncInstance(instanceId: string): void {
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
|
||||
}
|
||||
|
||||
isEnabled(instanceId: string): boolean {
|
||||
return this.aggregateByInstance.get(instanceId) === true
|
||||
}
|
||||
|
||||
private clearConnection(connection: ClientConnectionRef): void {
|
||||
const key = getConnectionKey(connection)
|
||||
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
|
||||
if (!enabledConnections.delete(key)) continue
|
||||
if (enabledConnections.size === 0) {
|
||||
this.enabledConnectionsByInstance.delete(instanceId)
|
||||
}
|
||||
this.publishIfChanged(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private publishIfChanged(instanceId: string): void {
|
||||
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
|
||||
const previous = this.aggregateByInstance.get(instanceId) === true
|
||||
if (enabled === previous) return
|
||||
|
||||
if (enabled) {
|
||||
this.aggregateByInstance.set(instanceId, true)
|
||||
} else {
|
||||
this.aggregateByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoiceModeEvent(enabled: boolean) {
|
||||
return {
|
||||
type: "codenomad.voiceMode",
|
||||
properties: {
|
||||
enabled,
|
||||
formatVersion: "v1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionKey(connection: ClientConnectionRef): string {
|
||||
return `${connection.clientId}:${connection.connectionId}`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict"
|
||||
import os from "node:os"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||
|
||||
describe("resolveNetworkAddresses", () => {
|
||||
it("preserves interface order among external addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "10.0.0.8", family: 4, internal: false },
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((entry) => entry.ip),
|
||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRemoteAddresses", () => {
|
||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers private LAN addresses over public addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a public address when no private LAN address is available", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function usingMockedNetworkInterfaces(
|
||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||
callback: () => void,
|
||||
) {
|
||||
const original = os.networkInterfaces
|
||||
os.networkInterfaces = (() => ({
|
||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||
})) as typeof os.networkInterfaces
|
||||
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
os.networkInterfaces = original
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,18 @@ import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
import type { SpeechService } from "../speech/service"
|
||||
import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -41,6 +47,7 @@ interface HttpServerDeps {
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -170,6 +177,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
logger: deps.logger.child({ component: "voice-mode" }),
|
||||
})
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
@@ -245,14 +259,27 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerEventRoutes(app, {
|
||||
eventBus: deps.eventBus,
|
||||
registerClient: registerSseClient,
|
||||
logger: sseLogger,
|
||||
connectionManager: clientConnectionManager,
|
||||
})
|
||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerPluginRoutes(app, {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: proxyLogger,
|
||||
channel: pluginChannel,
|
||||
voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -317,6 +344,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
clientConnectionManager.shutdown()
|
||||
return app.close()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemoteAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): ResolvedRemoteAddresses {
|
||||
const all = resolveNetworkAddresses(args)
|
||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||
return {
|
||||
all,
|
||||
userVisible,
|
||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||
}
|
||||
|
||||
function getUserVisiblePriority(ip: string): number {
|
||||
if (isPrivateIPv4(ip)) return 0
|
||||
if (isLinkLocalIPv4(ip)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function isLinkLocalIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
return first === 169 && second === 254
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
|
||||
if (first === 10) return true
|
||||
if (first === 192 && second === 168) return true
|
||||
return first === 172 && second >= 16 && second <= 31
|
||||
}
|
||||
|
||||
function parseIPv4(value: string): number[] | null {
|
||||
if (!isIPv4Address(value)) return null
|
||||
return value.split(".").map((part) => Number(part))
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import type { ClientConnectionManager } from "../../clients/connection-manager"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
connectionManager: ClientConnectionManager
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
const ConnectionQuerySchema = z.object({
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const PongBodySchema = ConnectionQuerySchema.extend({
|
||||
pingTs: z.number().optional(),
|
||||
})
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
const connection = ConnectionQuerySchema.parse(request.query ?? {})
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
const unsubscribe = deps.eventBus.onEvent(send)
|
||||
const heartbeat = setInterval(() => {
|
||||
reply.raw.write(`:hb ${Date.now()}\n\n`)
|
||||
const ping = { ts: Date.now() }
|
||||
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
|
||||
}, 15000)
|
||||
|
||||
let closed = false
|
||||
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
const unregisterConnection = deps.connectionManager.register({
|
||||
...connection,
|
||||
close,
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
close()
|
||||
unregister()
|
||||
unregisterConnection()
|
||||
}
|
||||
|
||||
request.raw.on("close", handleClose)
|
||||
request.raw.on("error", handleClose)
|
||||
})
|
||||
|
||||
app.post("/api/client-connections/pong", (request, reply) => {
|
||||
const body = PongBodySchema.parse(request.body ?? {})
|
||||
if (!deps.connectionManager.pong(body)) {
|
||||
reply.code(404).send({ error: "Client connection not found" })
|
||||
return
|
||||
}
|
||||
reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { VoiceModeStateResponse } from "../../api-types"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
import { VoiceModeManager } from "../../plugins/voice-mode"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
channel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
const VoiceModeStateSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
clientId: z.string().trim().min(1),
|
||||
connectionId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const registration = channel.register(request.params.id, reply)
|
||||
const registration = deps.channel.register(request.params.id, reply)
|
||||
deps.voiceModeManager.syncInstance(request.params.id)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
deps.channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
@@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||
deps.voiceModeManager.setEnabled(
|
||||
request.params.id,
|
||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||
payload.enabled,
|
||||
)
|
||||
return { enabled: payload.enabled }
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
|
||||
166
packages/server/src/server/routes/remote-servers.ts
Normal file
166
packages/server/src/server/routes/remote-servers.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteServerProbeResponse } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const ProbeSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-servers/probe", async (request, reply) => {
|
||||
try {
|
||||
const body = ProbeSchema.parse(request.body ?? {})
|
||||
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to probe remote server")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid request" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
|
||||
try {
|
||||
const response = await fetch(probeUrl, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: `Remote server returned HTTP ${response.status}`,
|
||||
errorCode: "http_error",
|
||||
}
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { authenticated?: unknown }
|
||||
if (typeof payload?.authenticated !== "boolean") {
|
||||
return {
|
||||
ok: false,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: "Remote server did not return a valid CodeNomad auth response",
|
||||
errorCode: "invalid_server",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reachable: true,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: !payload.authenticated,
|
||||
authenticated: payload.authenticated,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = describeProbeError(error)
|
||||
return {
|
||||
ok: false,
|
||||
reachable: false,
|
||||
normalizedUrl,
|
||||
skipTlsVerify,
|
||||
requiresAuth: false,
|
||||
authenticated: false,
|
||||
error: message.message,
|
||||
errorCode: message.code,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): string {
|
||||
const parsed = new URL(input.trim())
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Server URL must use http:// or https://")
|
||||
}
|
||||
|
||||
parsed.hash = ""
|
||||
parsed.search = ""
|
||||
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||
const value = parsed.toString()
|
||||
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function describeProbeError(error: unknown): { code: string; message: string } {
|
||||
const chain = unwrapErrorChain(error)
|
||||
const detailed =
|
||||
chain.find((entry) => {
|
||||
const code = (entry?.code ?? "").toString()
|
||||
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
|
||||
}) ?? chain[0]
|
||||
|
||||
const code = (detailed?.code ?? "").toString()
|
||||
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
|
||||
|
||||
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
|
||||
return {
|
||||
code: "tls_error",
|
||||
message: "Certificate check failed while connecting to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code:
|
||||
code === "ERR_INVALID_URL"
|
||||
? "invalid_url"
|
||||
: code === "ECONNREFUSED"
|
||||
? "connection_refused"
|
||||
: code === "ENOTFOUND"
|
||||
? "dns_error"
|
||||
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
|
||||
? "timeout"
|
||||
: code
|
||||
? code.toLowerCase()
|
||||
: "probe_failed",
|
||||
message: exactMessage || "Failed to connect to the remote server.",
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
|
||||
const results: Array<{ code?: unknown; message?: string }> = []
|
||||
let current: unknown = error
|
||||
const seen = new Set<unknown>()
|
||||
|
||||
while (current && typeof current === "object" && !seen.has(current)) {
|
||||
seen.add(current)
|
||||
const entry = current as { code?: unknown; message?: string; cause?: unknown }
|
||||
results.push({ code: entry.code, message: entry.message })
|
||||
current = entry.cause
|
||||
}
|
||||
|
||||
if (results.length === 0 && error instanceof Error) {
|
||||
results.push({ message: error.message })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
interface RouteDeps {
|
||||
settings: SettingsService
|
||||
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
|
||||
|
||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
// Full-document access
|
||||
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
|
||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||
app.patch("/api/storage/config", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
||||
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
|
||||
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||
return deps.settings.getOwner("config", request.params.owner)
|
||||
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
||||
return sanitizeConfigOwner(
|
||||
request.params.owner,
|
||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
||||
)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
|
||||
74
packages/server/src/server/routes/speech.ts
Normal file
74
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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", "aac"]).optional(),
|
||||
})
|
||||
|
||||
function getSpeechErrorStatus(error: unknown): number {
|
||||
if (error instanceof z.ZodError) {
|
||||
return 400
|
||||
}
|
||||
if (error instanceof Error && /not configured/i.test(error.message)) {
|
||||
return 503
|
||||
}
|
||||
return 502
|
||||
}
|
||||
|
||||
function getSpeechErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
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(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "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(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/speech/synthesize/stream", async (request, reply) => {
|
||||
try {
|
||||
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||
const result = await deps.speechService.synthesizeStream(body)
|
||||
reply.header("Content-Type", result.mimeType)
|
||||
reply.header("Cache-Control", "no-store")
|
||||
return reply.send(result.stream)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to stream synthesized audio")
|
||||
reply.code(getSpeechErrorStatus(error))
|
||||
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileContentBodySchema = z.object({
|
||||
contents: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.put<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
if (typeof listeningMode === "string") {
|
||||
serverConfig.listeningMode = listeningMode
|
||||
}
|
||||
const logLevel = preferences.logLevel
|
||||
if (typeof logLevel === "string") {
|
||||
serverConfig.logLevel = logLevel
|
||||
}
|
||||
const lastUsedBinary = preferences.lastUsedBinary
|
||||
if (typeof lastUsedBinary === "string") {
|
||||
serverConfig.opencodeBinary = lastUsedBinary
|
||||
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
|
||||
const moved = new Set([
|
||||
"environmentVariables",
|
||||
"listeningMode",
|
||||
"logLevel",
|
||||
"lastUsedBinary",
|
||||
"modelRecents",
|
||||
"modelFavorites",
|
||||
|
||||
40
packages/server/src/settings/public-config.ts
Normal file
40
packages/server/src/settings/public-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { SettingsDoc } from "./yaml-doc-store"
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
|
||||
const next: SettingsDoc = { ...value }
|
||||
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
|
||||
|
||||
if (!speech) {
|
||||
return next
|
||||
}
|
||||
|
||||
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
|
||||
if (rawApiKey) {
|
||||
delete speech.apiKey
|
||||
speech.hasApiKey = true
|
||||
} else if (!("hasApiKey" in speech)) {
|
||||
speech.hasApiKey = false
|
||||
}
|
||||
|
||||
next.speech = speech
|
||||
return next
|
||||
}
|
||||
|
||||
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
|
||||
if (owner !== "server") {
|
||||
return value
|
||||
}
|
||||
return sanitizeServerOwner(value)
|
||||
}
|
||||
|
||||
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
|
||||
const next: SettingsDoc = { ...value }
|
||||
if (isPlainObject(next.server)) {
|
||||
next.server = sanitizeServerOwner(next.server)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -1,12 +1,62 @@
|
||||
import type { Logger } from "../logger"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { ConfigLocation } from "../config/location"
|
||||
import { z } from "zod"
|
||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
const CanonicalLogLevelSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
|
||||
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
|
||||
)
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(value)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const next: SettingsDoc = { ...value }
|
||||
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
|
||||
if (parsedLogLevel.success) {
|
||||
next.logLevel = parsedLogLevel.data
|
||||
} else if (next.logLevel !== undefined) {
|
||||
next.logLevel = "DEBUG"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
|
||||
if (!isPlainObject(doc)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (!isPlainObject(doc.server)) {
|
||||
return doc
|
||||
}
|
||||
|
||||
return {
|
||||
...doc,
|
||||
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsService {
|
||||
private readonly configStore: YamlDocStore
|
||||
private readonly stateStore: YamlDocStore
|
||||
@@ -22,22 +72,44 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.get()
|
||||
}
|
||||
|
||||
const current = this.configStore.get()
|
||||
const normalized = normalizeConfigDoc(current)
|
||||
if (!isDeepEqual(current, normalized)) {
|
||||
this.configStore.replace(normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
}
|
||||
|
||||
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||
if (kind !== "config") {
|
||||
return this.stateStore.getOwner(owner)
|
||||
}
|
||||
|
||||
return owner === "server"
|
||||
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
|
||||
: this.getDoc("config")[owner] as SettingsDoc
|
||||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
const updated =
|
||||
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
return updated
|
||||
}
|
||||
@@ -45,10 +117,11 @@ export class SettingsService {
|
||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||
if (!this.eventBus) return
|
||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||
const nextValue = value ?? this.getOwner(kind, owner)
|
||||
const payload: WorkspaceEventPayload = {
|
||||
type,
|
||||
owner,
|
||||
value: value ?? this.getOwner(kind, owner),
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
}
|
||||
|
||||
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Readable } from "node:stream"
|
||||
import OpenAI from "openai"
|
||||
import { toFile } from "openai/uploads"
|
||||
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, 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,
|
||||
supportsStreamingTts: true,
|
||||
baseUrl: settings.baseUrl,
|
||||
sttModel: settings.sttModel,
|
||||
ttsModel: settings.ttsModel,
|
||||
ttsVoice: settings.ttsVoice,
|
||||
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
||||
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
||||
}
|
||||
}
|
||||
|
||||
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 format = input.format ?? this.options.settings.ttsFormat
|
||||
|
||||
this.options.logger.info(
|
||||
{
|
||||
model: this.options.settings.ttsModel,
|
||||
voice: this.options.settings.ttsVoice,
|
||||
format,
|
||||
},
|
||||
"speech.synthesize",
|
||||
)
|
||||
|
||||
const response = await this.requestSpeechAudio(input.text, format)
|
||||
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
|
||||
|
||||
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||
return {
|
||||
audioBase64: audioBuffer.toString("base64"),
|
||||
mimeType,
|
||||
}
|
||||
}
|
||||
|
||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||
const format = input.format ?? this.options.settings.ttsFormat
|
||||
|
||||
this.options.logger.info(
|
||||
{
|
||||
model: this.options.settings.ttsModel,
|
||||
voice: this.options.settings.ttsVoice,
|
||||
format,
|
||||
},
|
||||
"speech.synthesize.stream",
|
||||
)
|
||||
|
||||
const response = await this.requestSpeechAudio(input.text, format)
|
||||
if (!response.body) {
|
||||
throw new Error("Speech provider did not return a stream.")
|
||||
}
|
||||
|
||||
return {
|
||||
stream: Readable.fromWeb(response.body as any),
|
||||
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
|
||||
}
|
||||
}
|
||||
|
||||
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
|
||||
const { settings } = this.options
|
||||
if (!settings.apiKey) {
|
||||
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||
}
|
||||
|
||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
input: text,
|
||||
response_format: format,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
const detailedError = error as Error & {
|
||||
cause?: unknown
|
||||
code?: string
|
||||
errno?: number | string
|
||||
syscall?: string
|
||||
address?: string
|
||||
port?: number
|
||||
}
|
||||
this.options.logger.error(
|
||||
{
|
||||
err: error,
|
||||
endpoint: endpoint.toString(),
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
format,
|
||||
cause: detailedError.cause,
|
||||
code: detailedError.code,
|
||||
errno: detailedError.errno,
|
||||
syscall: detailedError.syscall,
|
||||
address: detailedError.address,
|
||||
port: detailedError.port,
|
||||
},
|
||||
"speech.synthesize fetch failed",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
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" | "aac"): string {
|
||||
if (format === "wav") return "audio/wav"
|
||||
if (format === "opus") return 'audio/ogg; codecs="opus"'
|
||||
if (format === "aac") return "audio/aac"
|
||||
return "audio/mpeg"
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`
|
||||
}
|
||||
106
packages/server/src/speech/service.ts
Normal file
106
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from "zod"
|
||||
import type { Readable } from "node:stream"
|
||||
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(),
|
||||
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export interface TranscribeAudioInput {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
filename?: string
|
||||
language?: string
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export interface SynthesizeSpeechInput {
|
||||
text: string
|
||||
format?: "mp3" | "wav" | "opus" | "aac"
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisStreamResponse {
|
||||
stream: Readable
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface SpeechProvider {
|
||||
getCapabilities(): SpeechCapabilitiesResponse
|
||||
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
||||
}
|
||||
|
||||
export interface NormalizedSpeechSettings {
|
||||
provider: string
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
ttsFormat: "mp3" | "wav" | "opus" | "aac"
|
||||
}
|
||||
|
||||
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"
|
||||
const DEFAULT_TTS_FORMAT = "mp3"
|
||||
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)
|
||||
}
|
||||
|
||||
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||
return this.createProvider().synthesizeStream(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,
|
||||
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
|
||||
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
||||
const bundledDir = path.join(tempRoot, "bundled")
|
||||
const configDir = path.join(tempRoot, "config")
|
||||
const currentDir = path.join(configDir, "ui", "current")
|
||||
|
||||
await mkdir(bundledDir, { recursive: true })
|
||||
await mkdir(currentDir, { recursive: true })
|
||||
|
||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
const result = await resolveUi({
|
||||
serverVersion: "0.8.1",
|
||||
bundledUiDir: bundledDir,
|
||||
autoUpdate: false,
|
||||
configDir,
|
||||
logger: noopLogger,
|
||||
})
|
||||
|
||||
assert.equal(result.source, "bundled")
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ export class WorkspaceManager {
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
@@ -136,12 +142,15 @@ export class WorkspaceManager {
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
logLevel,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ interface LaunchOptions {
|
||||
folder: string
|
||||
binaryPath: string
|
||||
environment?: Record<string, string>
|
||||
logLevel?: string
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
|
||||
2474
packages/tauri-app/Cargo.lock
generated
2474
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"dev:prep": "node ./scripts/dev-prep.js",
|
||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
|
||||
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const uiDevInstallCommand =
|
||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||
|
||||
const envWithRootBin = {
|
||||
...process.env,
|
||||
@@ -55,11 +56,7 @@ async function ensureMonacoAssets() {
|
||||
function ensureServerBuild() {
|
||||
const distPath = path.join(serverRoot, "dist")
|
||||
const publicPath = path.join(serverRoot, "public")
|
||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] server build missing; running workspace build...")
|
||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
@@ -91,6 +88,15 @@ function ensureUiBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerUiBundle() {
|
||||
console.log("[prebuild] syncing server public UI bundle...")
|
||||
execSync(serverPrepareUiCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
@@ -246,6 +252,7 @@ function copyUiLoadingAssets() {
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
syncServerUiBundle()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
copyUiLoadingAssets()
|
||||
|
||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const packageJsonPath = path.join(root, "package.json")
|
||||
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||
|
||||
function readPackageVersion() {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||
}
|
||||
return packageJson.version
|
||||
}
|
||||
|
||||
function syncCargoToml(version) {
|
||||
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoTomlPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncCargoLock(version) {
|
||||
if (!fs.existsSync(cargoLockPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||
const match = current.match(packageVersionPattern)
|
||||
|
||||
if (!match) {
|
||||
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||
}
|
||||
|
||||
if (match[2] === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||
fs.writeFileSync(cargoLockPath, updated)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTauriConfig(version) {
|
||||
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||
const config = JSON.parse(current)
|
||||
if (config.version === version) {
|
||||
return false
|
||||
}
|
||||
|
||||
config.version = version
|
||||
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
return true
|
||||
}
|
||||
|
||||
function main() {
|
||||
const version = readPackageVersion()
|
||||
const changed = []
|
||||
|
||||
if (syncCargoToml(version)) {
|
||||
changed.push(path.relative(root, cargoTomlPath))
|
||||
}
|
||||
|
||||
if (syncCargoLock(version)) {
|
||||
changed.push(path.relative(root, cargoLockPath))
|
||||
}
|
||||
|
||||
if (syncTauriConfig(version)) {
|
||||
changed.push(path.relative(root, tauriConfigPath))
|
||||
}
|
||||
|
||||
if (changed.length === 0) {
|
||||
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
version = "0.13.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
@@ -19,9 +19,13 @@ thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||
|
||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"opener:allow-open-url",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2379,34 +2379,70 @@
|
||||
"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`",
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all 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`",
|
||||
|
||||
@@ -2379,34 +2379,70 @@
|
||||
"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`",
|
||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
||||
"type": "string",
|
||||
"const": "keepawake:default",
|
||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
||||
"const": "global-shortcut:default",
|
||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
||||
},
|
||||
{
|
||||
"description": "Enables the start command without any pre-configured scope.",
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-start",
|
||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stop command without any pre-configured scope.",
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:allow-stop",
|
||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the start command without any pre-configured scope.",
|
||||
"description": "Enables the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-start",
|
||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-register-all",
|
||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stop command without any pre-configured scope.",
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "keepawake:deny-stop",
|
||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
||||
"const": "global-shortcut:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:allow-unregister-all",
|
||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-register-all",
|
||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "global-shortcut:deny-unregister-all",
|
||||
"markdownDescription": "Denies the unregister_all 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`",
|
||||
|
||||
@@ -9,18 +9,34 @@ use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_spawn(command: &mut Command) {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn configure_spawn(_command: &mut Command) {}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
@@ -32,10 +48,52 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
#[cfg(windows)]
|
||||
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn configure_posix_process_group(command: &mut Command) {
|
||||
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||
// processes (login shell/tsx) without leaving the server orphaned.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::setpgid(0, 0) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||
if force {
|
||||
args.push("/F".to_string());
|
||||
}
|
||||
|
||||
let mut command = Command::new("taskkill");
|
||||
command.args(&args);
|
||||
configure_spawn(&mut command);
|
||||
|
||||
match command.output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the PID is already gone, treat it as success.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
combined.contains("not found") || combined.contains("no running instance")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -66,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -101,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -114,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -132,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_auth_cookie_name() -> String {
|
||||
let pid = std::process::id();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -346,13 +423,21 @@ impl CliProcessManager {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(windows)]
|
||||
let mut forced_tree_shutdown = false;
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
let pid = child.id() as i32;
|
||||
// Prefer signaling the process group to avoid orphaning children
|
||||
// when the CLI was launched via a wrapper shell.
|
||||
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
let _ = kill_process_tree_windows(child.id(), false);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
@@ -360,6 +445,21 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
#[cfg(windows)]
|
||||
if !forced_tree_shutdown
|
||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
||||
{
|
||||
log_line(&format!(
|
||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||
child.id()
|
||||
));
|
||||
forced_tree_shutdown = true;
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
@@ -368,11 +468,21 @@ impl CliProcessManager {
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
if !forced_tree_shutdown
|
||||
&& !kill_process_tree_windows(child.id(), true)
|
||||
{
|
||||
let _ = child.kill();
|
||||
} else if forced_tree_shutdown {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -412,7 +522,8 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -423,7 +534,9 @@ impl CliProcessManager {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
let use_user_shell = supports_user_shell();
|
||||
|
||||
let command_info = if use_user_shell {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
@@ -434,7 +547,7 @@ impl CliProcessManager {
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if !use_user_shell {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Node binary not found. Make sure Node.js is installed."
|
||||
@@ -448,11 +561,16 @@ impl CliProcessManager {
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.env_remove("npm_config_prefix")
|
||||
.env_remove("NPM_CONFIG_PREFIX")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
@@ -462,9 +580,12 @@ impl CliProcessManager {
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_spawn(&mut c);
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
configure_posix_process_group(&mut c);
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
@@ -487,6 +608,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -501,24 +623,41 @@ impl CliProcessManager {
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stdout",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app_clone,
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
);
|
||||
let app = app_clone.clone();
|
||||
let status = status_clone.clone();
|
||||
let ready = ready_clone.clone();
|
||||
let token = token_clone.clone();
|
||||
let auth_cookie_name = auth_cookie_name_clone.clone();
|
||||
thread::spawn(move || {
|
||||
Self::process_stream(
|
||||
reader,
|
||||
"stderr",
|
||||
&app,
|
||||
&status,
|
||||
&ready,
|
||||
&token,
|
||||
auth_cookie_name.as_str(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -537,7 +676,24 @@ impl CliProcessManager {
|
||||
locked.error = Some("CLI did not start in time".to_string());
|
||||
log_line("timeout waiting for CLI readiness");
|
||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||
let _ = child.kill();
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
let pid = child.id() as i32;
|
||||
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||
if group_res != 0 {
|
||||
let _ = libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if !kill_process_tree_windows(child.id(), true) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
@@ -617,10 +773,10 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||
|
||||
loop {
|
||||
@@ -652,39 +808,17 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_lowercase().contains("http server listening") {
|
||||
if let Some(port) = http_regex
|
||||
.as_ref()
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -697,6 +831,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -720,9 +855,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -818,11 +955,13 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
|
||||
@@ -879,27 +1018,50 @@ impl CliEntry {
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let mut candidates = vec![
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||
std::env::current_exe().ok().and_then(|ex| {
|
||||
ex.parent()
|
||||
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||
}),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
|
||||
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let cwd = std::env::current_dir().ok();
|
||||
let workspace = workspace_root();
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
workspace
|
||||
.as_ref()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
|
||||
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
@@ -1001,11 +1163,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if shell_name.contains("zsh") {
|
||||
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||
} else {
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
let _ = shell_name;
|
||||
vec!["-l".into(), "-c".into(), command.into()]
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
|
||||
@@ -3,20 +3,63 @@
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
#[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);
|
||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.2;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct WakeLockConfig {
|
||||
display: bool,
|
||||
idle: bool,
|
||||
sleep: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -35,6 +78,39 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_start(
|
||||
state: tauri::State<AppState>,
|
||||
config: Option<WakeLockConfig>,
|
||||
) -> Result<(), String> {
|
||||
let config = config.unwrap_or(WakeLockConfig {
|
||||
display: true,
|
||||
idle: false,
|
||||
sleep: false,
|
||||
});
|
||||
|
||||
let mut builder = keepawake::Builder::default();
|
||||
builder
|
||||
.display(config.display)
|
||||
.idle(config.idle)
|
||||
.sleep(config.sleep)
|
||||
.reason("CodeNomad active session")
|
||||
.app_name("CodeNomad")
|
||||
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||
|
||||
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
*state_lock = Some(wake_lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||
state_lock.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
@@ -53,11 +129,28 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let Ok(allowed) = state.remote_origins.lock() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(origin) = allowed.get(window_label) {
|
||||
return origin == &url.origin().ascii_serialization();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
let window_label = webview.label().to_string();
|
||||
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
@@ -68,6 +161,53 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||
return Err(
|
||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||
let label = format!("remote-{}", payload.id);
|
||||
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str()));
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
let _ = existing.set_focus();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
@@ -101,6 +241,99 @@ fn emit_folder_drop_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_zoom_level(value: f64) -> f64 {
|
||||
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
|
||||
}
|
||||
|
||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let normalized = clamp_zoom_level(next_zoom);
|
||||
if window.set_zoom(normalized).is_ok() {
|
||||
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
*zoom_level = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn force_reload_main_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if let Ok(mut url) = window.url() {
|
||||
if should_allow_internal(&url) {
|
||||
let reload_token = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string();
|
||||
|
||||
let existing_pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.into_owned()
|
||||
.filter(|(key, _)| key != "__codenomad_force_reload")
|
||||
.collect();
|
||||
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.clear();
|
||||
for (key, value) in existing_pairs {
|
||||
pairs.append_pair(&key, &value);
|
||||
}
|
||||
pairs.append_pair("__codenomad_force_reload", &reload_token);
|
||||
}
|
||||
|
||||
let _ = window.navigate(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = window.reload();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen_window(app_handle: &AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
|
||||
let _ = window.set_fullscreen(next_fullscreen);
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
if next_fullscreen {
|
||||
let _ = window.hide_menu();
|
||||
} else {
|
||||
let _ = window.show_menu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fullscreen_shortcut() -> Option<Shortcut> {
|
||||
if cfg!(target_os = "macos") {
|
||||
None
|
||||
} else {
|
||||
Some(Shortcut::new(None, ShortcutCode::F11))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_windows_app_user_model_id() {
|
||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||
.encode_wide()
|
||||
.chain(iter::once(0))
|
||||
.collect();
|
||||
|
||||
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||
if result < 0 {
|
||||
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -109,14 +342,49 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state() != ShortcutState::Pressed {
|
||||
return;
|
||||
}
|
||||
|
||||
if fullscreen_shortcut().as_ref() == Some(shortcut) {
|
||||
toggle_fullscreen_window(app);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(focused) = event {
|
||||
let shortcut_manager = app_handle.global_shortcut();
|
||||
if *focused {
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
} else {
|
||||
let _ = shortcut_manager.unregister(shortcut.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
@@ -127,7 +395,13 @@ fn main() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
cli_get_status,
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
@@ -136,36 +410,42 @@ fn main() {
|
||||
let _ = window.emit("menu:newInstance", ());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
|
||||
// View menu
|
||||
"reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload()");
|
||||
}
|
||||
reload_main_window(app_handle);
|
||||
}
|
||||
"force_reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload(true)");
|
||||
}
|
||||
force_reload_main_window(app_handle);
|
||||
}
|
||||
"toggle_devtools" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
if window.is_devtools_open() {
|
||||
window.close_devtools();
|
||||
} else {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
}
|
||||
"reset_zoom" => {
|
||||
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
|
||||
}
|
||||
"zoom_in" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
"zoom_out" => {
|
||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
||||
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
"toggle_fullscreen" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||
}
|
||||
toggle_fullscreen_window(app_handle);
|
||||
}
|
||||
|
||||
// Window menu
|
||||
@@ -179,6 +459,11 @@ fn main() {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
"close_window" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.close();
|
||||
}
|
||||
}
|
||||
|
||||
// App menu (macOS)
|
||||
"about" => {
|
||||
@@ -247,11 +532,24 @@ fn main() {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
// Let windows close normally. App shutdown is handled only after the
|
||||
// last window is actually gone so remote windows can outlive `main`.
|
||||
let _ = api;
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if !app_handle.webview_windows().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the CLI only when the final window is gone and the app is
|
||||
// truly exiting.
|
||||
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
api.prevent_close();
|
||||
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -266,6 +564,7 @@ fn main() {
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let is_mac = cfg!(target_os = "macos");
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
// Create submenus
|
||||
let mut submenus = Vec::new();
|
||||
@@ -293,16 +592,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(
|
||||
if is_mac { "close" } else { "quit" },
|
||||
if is_mac { "Close" } else { "Quit" },
|
||||
)
|
||||
.build()?;
|
||||
let file_menu = if is_mac {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text("quit", "Quit")
|
||||
.build()?
|
||||
};
|
||||
submenus.push(file_menu);
|
||||
|
||||
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
|
||||
let force_reload_item = MenuItem::with_id(
|
||||
app,
|
||||
"force_reload",
|
||||
"Force Reload",
|
||||
true,
|
||||
Some("CmdOrCtrl+Shift+R"),
|
||||
)?;
|
||||
let toggle_devtools_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_devtools",
|
||||
"Toggle Developer Tools",
|
||||
true,
|
||||
Some("Alt+CmdOrCtrl+I"),
|
||||
)?;
|
||||
let reset_zoom_item =
|
||||
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
|
||||
let zoom_in_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_in",
|
||||
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let zoom_out_item = MenuItem::with_id(
|
||||
app,
|
||||
"zoom_out",
|
||||
if is_mac {
|
||||
"Zoom Out"
|
||||
} else {
|
||||
"Zoom Out\tCtrl+-"
|
||||
},
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_fullscreen_item = MenuItem::with_id(
|
||||
app,
|
||||
"toggle_fullscreen",
|
||||
if is_mac {
|
||||
"Toggle Full Screen"
|
||||
} else {
|
||||
"Toggle Full Screen\tF11"
|
||||
},
|
||||
true,
|
||||
if is_mac {
|
||||
Some("Ctrl+Cmd+F")
|
||||
} else {
|
||||
None::<&str>
|
||||
},
|
||||
)?;
|
||||
let close_window_item =
|
||||
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
|
||||
|
||||
// Edit menu with predefined items for standard functionality
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
@@ -318,20 +675,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
// View menu
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.text("reload", "Reload")
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.item(&reload_item)
|
||||
.item(&force_reload_item)
|
||||
.item(&toggle_devtools_item)
|
||||
.separator()
|
||||
.item(&reset_zoom_item)
|
||||
.item(&zoom_in_item)
|
||||
.item(&zoom_out_item)
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.item(&toggle_fullscreen_item)
|
||||
.build()?;
|
||||
submenus.push(view_menu);
|
||||
|
||||
// Window menu
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.build()?;
|
||||
let window_menu = if is_linux {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.separator()
|
||||
.item(&close_window_item)
|
||||
.build()?
|
||||
} else if is_mac {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.build()?
|
||||
} else {
|
||||
SubmenuBuilder::new(app, "Window")
|
||||
.minimize()
|
||||
.maximize()
|
||||
.separator()
|
||||
.close_window()
|
||||
.build()?
|
||||
};
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"version": "0.13.3",
|
||||
"identifier": "ai.neuralnomads.codenomad.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
@@ -33,9 +30,13 @@
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
"scope": [
|
||||
"**"
|
||||
]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
"capabilities": [
|
||||
"main-window-native-dialogs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -44,7 +45,17 @@
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
"icon": [
|
||||
"icon.icns",
|
||||
"icon.ico",
|
||||
"icon.png"
|
||||
],
|
||||
"targets": [
|
||||
"app",
|
||||
"appimage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"nsis"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.2",
|
||||
"version": "0.13.3",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -18,8 +18,10 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -30,7 +32,7 @@
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||
"virtua": "^0.48.8",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { SettingsScreen } from "./components/settings-screen"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
@@ -71,6 +68,7 @@ const App: Component = () => {
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -183,10 +181,6 @@ const App: Component = () => {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
@@ -360,6 +354,7 @@ const App: Component = () => {
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
|
||||
@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only handle dismiss if dialog is dismissible (default: true)
|
||||
if (!open && payload.dismissible !== false) {
|
||||
dismiss(false, payload)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<Dialog.Overlay class="modal-overlay z-[60]" />
|
||||
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">
|
||||
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="prompt-input"
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { getSharedHighlighter } from "../lib/markdown"
|
||||
import { escapeHtml } from "../lib/text-render-utils"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createMemo, Show, createEffect } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||
import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
@@ -19,6 +20,7 @@ interface ToolCallDiffViewerProps {
|
||||
filePath?: string
|
||||
theme: "light" | "dark"
|
||||
mode: DiffViewMode
|
||||
wrap?: boolean
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
@@ -30,11 +32,183 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
|
||||
const computed = window.getComputedStyle(source)
|
||||
const probe = document.createElement("span")
|
||||
probe.textContent = text || ""
|
||||
probe.style.position = "absolute"
|
||||
probe.style.visibility = "hidden"
|
||||
probe.style.pointerEvents = "none"
|
||||
probe.style.display = "inline-block"
|
||||
probe.style.width = "auto"
|
||||
probe.style.maxWidth = "none"
|
||||
probe.style.whiteSpace = "nowrap"
|
||||
probe.style.fontFamily = computed.fontFamily
|
||||
probe.style.fontSize = computed.fontSize
|
||||
probe.style.fontWeight = computed.fontWeight
|
||||
probe.style.fontStyle = computed.fontStyle
|
||||
probe.style.letterSpacing = computed.letterSpacing
|
||||
probe.style.fontVariant = computed.fontVariant
|
||||
probe.style.textTransform = computed.textTransform
|
||||
probe.style.lineHeight = computed.lineHeight
|
||||
container.appendChild(probe)
|
||||
const width = Math.ceil(probe.getBoundingClientRect().width)
|
||||
probe.remove()
|
||||
return width
|
||||
}
|
||||
|
||||
function computeCompactWidth(
|
||||
container: HTMLElement,
|
||||
entries: Array<{ text: string; source: HTMLElement }>,
|
||||
maxWidthPx = 40,
|
||||
) {
|
||||
const measuredLabelWidthPx = entries.reduce((max, entry) => {
|
||||
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
|
||||
}, 0)
|
||||
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
|
||||
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
|
||||
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
|
||||
}
|
||||
|
||||
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
|
||||
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
|
||||
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
|
||||
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
|
||||
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
|
||||
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
|
||||
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
|
||||
|
||||
if (table) {
|
||||
if (wrap) {
|
||||
table.classList.add("table-fixed")
|
||||
table.style.tableLayout = "fixed"
|
||||
table.style.width = "100%"
|
||||
table.style.minWidth = "100%"
|
||||
} else {
|
||||
table.classList.remove("table-fixed")
|
||||
table.style.tableLayout = "auto"
|
||||
table.style.width = "max-content"
|
||||
table.style.minWidth = "100%"
|
||||
}
|
||||
}
|
||||
|
||||
gutterRows.forEach((gutter) => {
|
||||
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
|
||||
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
|
||||
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
|
||||
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
|
||||
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
|
||||
|
||||
const oldText = oldSpan?.textContent?.trim() ?? ""
|
||||
const newText = newSpan?.textContent?.trim() ?? ""
|
||||
const hasUsableNew = newText.length > 0 && newText !== "0"
|
||||
const hasUsableOld = oldText.length > 0 && oldText !== "0"
|
||||
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
|
||||
|
||||
if (flexWrapper) flexWrapper.style.display = "none"
|
||||
if (spacer) spacer.style.display = "none"
|
||||
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
|
||||
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
|
||||
|
||||
gutter.style.paddingLeft = "1px"
|
||||
gutter.style.paddingRight = "1px"
|
||||
gutter.style.textAlign = "left"
|
||||
|
||||
const label = currentLabel ?? document.createElement("span")
|
||||
label.className = "tool-call-diff-compact-line-number"
|
||||
label.textContent = visibleText
|
||||
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
|
||||
if (!currentLabel) gutter.appendChild(label)
|
||||
|
||||
entries.push({ gutter, label, text: visibleText })
|
||||
})
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
|
||||
|
||||
if (tableWrapper) {
|
||||
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
|
||||
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
|
||||
}
|
||||
if (numberCol) {
|
||||
numberCol.style.width = gutterWidth
|
||||
}
|
||||
|
||||
entries.forEach(({ gutter, label }) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
label.style.width = "auto"
|
||||
label.style.maxWidth = "none"
|
||||
})
|
||||
|
||||
hunkGutters.forEach((gutter) => {
|
||||
gutter.style.width = gutterWidth
|
||||
gutter.style.minWidth = gutterWidth
|
||||
gutter.style.maxWidth = gutterWidth
|
||||
gutter.style.paddingLeft = "0"
|
||||
gutter.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactSplitGutter(container: HTMLElement) {
|
||||
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
|
||||
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
|
||||
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
|
||||
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
|
||||
const numberSpans = numberCells
|
||||
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
|
||||
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
|
||||
|
||||
const gutterWidthPx = computeCompactWidth(
|
||||
container,
|
||||
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
|
||||
64,
|
||||
)
|
||||
const gutterWidth = `${gutterWidthPx}px`
|
||||
|
||||
;[oldWrapper, newWrapper].forEach((wrapper) => {
|
||||
if (wrapper) {
|
||||
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
|
||||
}
|
||||
})
|
||||
|
||||
numberCells.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "2px"
|
||||
cell.style.paddingRight = "2px"
|
||||
cell.style.textAlign = "left"
|
||||
cell.style.whiteSpace = "nowrap"
|
||||
cell.style.overflowWrap = "normal"
|
||||
cell.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
numberSpans.forEach(({ span }) => {
|
||||
span.style.whiteSpace = "nowrap"
|
||||
span.style.overflowWrap = "normal"
|
||||
span.style.wordBreak = "normal"
|
||||
})
|
||||
|
||||
hunkActions.forEach((cell) => {
|
||||
cell.style.width = gutterWidth
|
||||
cell.style.minWidth = gutterWidth
|
||||
cell.style.maxWidth = gutterWidth
|
||||
cell.style.paddingLeft = "0"
|
||||
cell.style.paddingRight = "0"
|
||||
})
|
||||
}
|
||||
|
||||
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
|
||||
if (mode === "unified") {
|
||||
applyCompactUnifiedGutter(container, wrap)
|
||||
return
|
||||
}
|
||||
if (mode === "split") {
|
||||
applyCompactSplitGutter(container)
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
@@ -66,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
if (diffContainerRef) {
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
}
|
||||
// When we are given cached HTML, we rely on the caller's cache
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
@@ -82,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
@@ -94,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
wrap: props.wrap,
|
||||
})
|
||||
}
|
||||
props.onRendered?.()
|
||||
@@ -121,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewWrap={Boolean(props.wrap)}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
@@ -130,8 +309,8 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div innerHTML={props.cachedHtml} />
|
||||
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
|
||||
scopeKey: string
|
||||
path: string
|
||||
content: string
|
||||
onSave?: (content: string) => void
|
||||
onContentChange?: (content: string) => void
|
||||
}
|
||||
|
||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = null
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
if (!editor || !props.onSave) return
|
||||
props.onSave(editor.getValue())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
editor = monaco.editor.create(host, {
|
||||
value: "",
|
||||
language: "plaintext",
|
||||
readOnly: true,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
lineNumbers: "on",
|
||||
minimap: { enabled: false },
|
||||
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||
fontSize: 13,
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (props.onContentChange) {
|
||||
props.onContentChange(editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Select } from "@kobalte/core/select"
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
@@ -13,8 +14,15 @@ import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
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"
|
||||
|
||||
type HomeTab = "local" | "servers"
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
@@ -24,12 +32,30 @@ interface FolderSelectionViewProps {
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||
const {
|
||||
recentFolders,
|
||||
removeRecentFolder,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
serverSettings,
|
||||
remoteServers,
|
||||
saveRemoteServerProfile,
|
||||
markRemoteServerConnected,
|
||||
removeRemoteServerProfile,
|
||||
} = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
|
||||
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverUrl, setServerUrl] = createSignal("")
|
||||
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
|
||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -42,13 +68,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "zh-Hans", label: "简体中文" },
|
||||
{ value: "he", label: "עברית" },
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
}
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = serverSettings().opencodeBinary
|
||||
@@ -60,7 +92,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
function scrollToIndex(index: number) {
|
||||
const container = recentListRef
|
||||
if (!container) return
|
||||
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
|
||||
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
|
||||
if (!element) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
@@ -109,19 +141,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const folderList = folders()
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
if (folderList.length === 0) return
|
||||
const listLength = getActiveListLength()
|
||||
if (listLength === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -134,7 +165,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -152,7 +183,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = folderList.length - 1
|
||||
const newIndex = listLength - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("recent")
|
||||
scrollToIndex(newIndex)
|
||||
@@ -161,10 +192,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (folderList.length > 0 && focusMode() === "recent") {
|
||||
const folder = folderList[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
if (listLength > 0 && focusMode() === "recent") {
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
} else {
|
||||
const server = serverList()[selectedIndex()]
|
||||
if (server) {
|
||||
removeRemoteServerProfile(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,15 +211,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
function handleEnterKey() {
|
||||
if (isLoading()) return
|
||||
const folderList = folders()
|
||||
const index = selectedIndex()
|
||||
|
||||
const folder = folderList[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[index]
|
||||
if (folder) {
|
||||
handleFolderSelect(folder.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const server = serverList()[index]
|
||||
if (server) {
|
||||
void handleConnectSavedServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const length = getActiveListLength()
|
||||
if (length === 0) {
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedIndex() >= length) {
|
||||
setSelectedIndex(length - 1)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
@@ -232,11 +295,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
function resetServerDialog() {
|
||||
setServerName("")
|
||||
setServerUrl("")
|
||||
setSkipTlsVerify(false)
|
||||
setServerDialogError(null)
|
||||
}
|
||||
|
||||
|
||||
function openServerDialog() {
|
||||
resetServerDialog()
|
||||
setIsServerDialogOpen(true)
|
||||
}
|
||||
|
||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||
const trimmedName = input.name.trim()
|
||||
const trimmedUrl = input.baseUrl.trim()
|
||||
if (!trimmedName || !trimmedUrl) {
|
||||
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
|
||||
}
|
||||
|
||||
const probe = await serverApi.probeRemoteServer({
|
||||
baseUrl: trimmedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (!probe.ok) {
|
||||
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
|
||||
}
|
||||
|
||||
const profile = await saveRemoteServerProfile({
|
||||
id: input.id,
|
||||
name: trimmedName,
|
||||
baseUrl: probe.normalizedUrl,
|
||||
skipTlsVerify: input.skipTlsVerify,
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
await openRemoteServerWindow(profile)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
async function handleSaveServer(openWindow: boolean) {
|
||||
if (isSavingServer()) return
|
||||
setIsSavingServer(true)
|
||||
setServerDialogError(null)
|
||||
try {
|
||||
await probeAndOpenServer(
|
||||
{
|
||||
name: serverName(),
|
||||
baseUrl: serverUrl(),
|
||||
skipTlsVerify: skipTlsVerify(),
|
||||
},
|
||||
openWindow,
|
||||
)
|
||||
setIsServerDialogOpen(false)
|
||||
resetServerDialog()
|
||||
} catch (error) {
|
||||
setServerDialogError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsSavingServer(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
try {
|
||||
await probeAndOpenServer(target, true)
|
||||
} catch (error) {
|
||||
showAlertDialog(error instanceof Error ? error.message : String(error), {
|
||||
title: t("folderSelection.servers.errorTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
} finally {
|
||||
setConnectingServerId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -343,7 +482,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
||||
<Select<LanguageOption>
|
||||
value={selectedLanguageOption()}
|
||||
onChange={(value) => {
|
||||
@@ -387,7 +526,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -425,7 +564,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -433,21 +572,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.github")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label={t("folderSelection.links.githubStars")}
|
||||
title={t("folderSelection.links.githubStars")}
|
||||
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
@@ -456,7 +595,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
href={DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
@@ -464,9 +603,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
title={t("folderSelection.links.discord")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
@@ -479,90 +616,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||
<p class="panel-subtitle">
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div class="panel-header !gap-0 !p-0">
|
||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "local",
|
||||
"text-muted hover:text-secondary": activeTab() !== "local",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("local")}
|
||||
>
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
{t("folderSelection.recent.title")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
folders().length === 1
|
||||
? "folderSelection.recent.subtitle.one"
|
||||
: "folderSelection.recent.subtitle.other",
|
||||
{ count: folders().length },
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "servers",
|
||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
<div
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={remoteServers().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Globe class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={remoteServers()}>
|
||||
{(server, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
onClick={() => void handleConnectSavedServer(server.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd">↵</kbd></Show>}>
|
||||
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeRemoteServerProfile(server.id)}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.servers.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-list-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{splitFolderPath(folder.path).baseName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||
{getDisplayPath(folder.path)}
|
||||
</span>
|
||||
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title={t("folderSelection.recent.remove")}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -570,27 +840,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
|
||||
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
<div class="panel-body flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>
|
||||
{props.isLoading
|
||||
? t("folderSelection.browse.buttonOpening")
|
||||
: t("folderSelection.browse.button")}
|
||||
</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
@@ -666,6 +946,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">
|
||||
{t("folderSelection.servers.dialog.title")}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
{t("folderSelection.servers.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.name")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverName()}
|
||||
onInput={(event) => setServerName(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2 text-sm text-secondary">
|
||||
<span>{t("folderSelection.servers.dialog.url")}</span>
|
||||
<input
|
||||
class="selector-input w-full"
|
||||
value={serverUrl()}
|
||||
onInput={(event) => setServerUrl(event.currentTarget.value)}
|
||||
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipTlsVerify()}
|
||||
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
|
||||
</label>
|
||||
|
||||
<Show when={serverDialogError()}>
|
||||
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
|
||||
{t("folderSelection.servers.dialog.cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(false)}
|
||||
>
|
||||
{t("folderSelection.servers.dialog.save")}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary w-auto px-4"
|
||||
disabled={isSavingServer()}
|
||||
onClick={() => void handleSaveServer(true)}
|
||||
>
|
||||
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
{t("folderSelection.servers.dialog.connecting")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
@@ -82,7 +83,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +95,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.project")}
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +138,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.binaryPath")}
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +152,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
{key}
|
||||
</span>
|
||||
|
||||
@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
dir="auto"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
|
||||
@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
|
||||
import { loadBackgroundProcesses } from "../../stores/background-processes"
|
||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
|
||||
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
|
||||
import SessionSidebar from "./shell/SessionSidebar"
|
||||
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
|
||||
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
|
||||
import { useDrawerResize } from "./shell/useDrawerResize"
|
||||
import { useSessionCache } from "./shell/useSessionCache"
|
||||
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import {
|
||||
canAutoRespondPermission,
|
||||
finishAutoRespondPermission,
|
||||
getPermissionAutoAcceptInFlightVersion,
|
||||
isPermissionAutoAcceptEnabled,
|
||||
} from "../../stores/permission-auto-accept"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -81,7 +88,8 @@ interface InstanceShellProps {
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const isRTL = () => locale() === "he"
|
||||
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||
@@ -96,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
// Worktree selector manages its own dialogs.
|
||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||
@@ -229,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instance.id)
|
||||
const connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -251,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return permissions + questions > 0
|
||||
})
|
||||
|
||||
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
|
||||
|
||||
createEffect(() => {
|
||||
getPermissionAutoAcceptInFlightVersion()
|
||||
|
||||
for (const permission of permissionQueue()) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (!sessionId) continue
|
||||
if (!permission?.id) continue
|
||||
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
|
||||
|
||||
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
|
||||
.catch((error) => {
|
||||
log.error("Failed to auto-accept permission", error)
|
||||
})
|
||||
.finally(() => {
|
||||
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const yoloModeEnabled = createMemo(() => {
|
||||
const session = activeSessionForInstance()
|
||||
if (!session) return false
|
||||
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
|
||||
})
|
||||
|
||||
const activeSessionStatusPill = createMemo(() => {
|
||||
const activeSessionId = activeSessionIdForInstance()
|
||||
if (!activeSessionId || activeSessionId === "info") return null
|
||||
@@ -271,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
}
|
||||
|
||||
const status = getSessionStatus(props.instance.id, activeSessionId)
|
||||
const text =
|
||||
status === "working"
|
||||
const retry = getSessionRetry(props.instance.id, activeSessionId)
|
||||
const text = retry
|
||||
? (() => {
|
||||
const seconds = getRetrySeconds(retry.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
})()
|
||||
: status === "working"
|
||||
? t("sessionList.status.working")
|
||||
: status === "compacting"
|
||||
? t("sessionList.status.compacting")
|
||||
: t("sessionList.status.idle")
|
||||
|
||||
return {
|
||||
className: `session-${status}`,
|
||||
className: `session-${retry ? "retrying" : status}`,
|
||||
text,
|
||||
showAlertIcon: false,
|
||||
title: retry
|
||||
? t("sessionList.status.retryTooltip", {
|
||||
message: retry.message,
|
||||
attempt: String(retry.attempt),
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -289,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const pill = activeSessionStatusPill()
|
||||
if (!pill) return null
|
||||
return (
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{pill.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderYoloModePill = () => {
|
||||
if (!yoloModeEnabled()) return null
|
||||
return (
|
||||
<span
|
||||
class="status-indicator session-status session-status-list session-yolo-mode"
|
||||
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
title={t("instanceShell.yoloMode.badgeAriaLabel")}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
{t("instanceShell.yoloMode.badge")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSessionHeaderIndicators = () => (
|
||||
<div class="flex items-center flex-wrap justify-center gap-2">
|
||||
{renderYoloModePill()}
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instance.id)
|
||||
}
|
||||
@@ -371,7 +450,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${sessionSidebarWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--border-base)",
|
||||
borderInlineEnd: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -413,16 +492,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
anchor={isRTL() ? "right" : "left"}
|
||||
variant="temporary"
|
||||
open={leftOpen()}
|
||||
onClose={closeLeftDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -480,7 +560,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${rightDrawerWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border-base)",
|
||||
borderInlineStart: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -523,16 +603,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
anchor={isRTL() ? "left" : "right"}
|
||||
variant="temporary"
|
||||
open={rightOpen()}
|
||||
onClose={closeRightDrawer}
|
||||
ModalProps={modalProps}
|
||||
sx={{
|
||||
zIndex: 60,
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -619,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
@@ -716,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="ml-auto flex items-center session-header-hints">
|
||||
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
|
||||
<PermissionNotificationBanner
|
||||
instanceId={props.instance.id}
|
||||
onClick={() => setPermissionModalOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
{renderSessionHeaderIndicators()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -742,7 +813,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
|
||||
@@ -48,104 +48,103 @@ interface SessionSidebarProps {
|
||||
}
|
||||
|
||||
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||
title={props.t("sessionList.actions.newSession.title")}
|
||||
onClick={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<PlusSquare class="w-5 h-5" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||
title={props.t("sessionList.filter.ariaLabel")}
|
||||
aria-pressed={props.showSearch()}
|
||||
onClick={props.onToggleSearch}
|
||||
sx={{
|
||||
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--surface-hover)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
<Search class="w-5 h-5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||
onClick={() => props.onSelectSession("info")}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Show when={!props.isPhoneLayout()}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||
>
|
||||
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Show>
|
||||
<Show when={props.drawerState() === "floating-open"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||
onClick={props.onCloseLeftDrawer}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
<Show when={props.keyboardShortcuts().length}>
|
||||
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||
<SessionList
|
||||
instanceId={props.instanceId}
|
||||
threads={props.threads()}
|
||||
activeSessionId={props.activeSessionId()}
|
||||
onSelect={props.onSelectSession}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar={props.showSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<div class="session-sidebar-separator" />
|
||||
<Show when={props.activeSession()}>
|
||||
{(activeSession) => (
|
||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||
|
||||
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||
showDescription={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
export default SessionSidebar
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
onCleanup,
|
||||
type Accessor,
|
||||
type Component,
|
||||
@@ -20,13 +22,11 @@ import type { Session } from "../../../../types/session"
|
||||
import type { DrawerViewState } from "../types"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||
|
||||
import ChangesTab from "./tabs/ChangesTab"
|
||||
import FilesTab from "./tabs/FilesTab"
|
||||
import GitChangesTab from "./tabs/GitChangesTab"
|
||||
import StatusTab from "./tabs/StatusTab"
|
||||
|
||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||
import { requestData } from "../../../../lib/opencode-api"
|
||||
import { serverApi } from "../../../../lib/api-client"
|
||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||
import { showToastNotification } from "../../../../lib/notifications"
|
||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||
import {
|
||||
@@ -49,6 +49,15 @@ import {
|
||||
readStoredRightPanelTab,
|
||||
} from "../storage"
|
||||
|
||||
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
|
||||
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
|
||||
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
|
||||
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
|
||||
|
||||
function RightPanelTabFallback() {
|
||||
return <div class="flex-1 min-h-0" />
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -80,6 +89,7 @@ interface RightPanelProps {
|
||||
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||
"yolo-mode",
|
||||
"plan",
|
||||
"background-processes",
|
||||
"mcp",
|
||||
@@ -96,6 +106,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
||||
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
||||
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
||||
|
||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||
@@ -243,7 +256,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
event.preventDefault()
|
||||
const delta = event.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
@@ -266,7 +280,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
const delta = touch.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
@@ -531,6 +546,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setBrowserSelectedLoading(true)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
setBrowserSelectedOriginalContent(null)
|
||||
|
||||
// Phone: treat file selection as a commit action and close the overlay.
|
||||
if (props.isPhoneLayout()) {
|
||||
@@ -551,6 +568,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -558,6 +576,95 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
||||
const path = browserSelectedPath()
|
||||
if (!path) return false
|
||||
|
||||
// Check for conflict: agent edited file while user was editing
|
||||
const originalContent = browserSelectedOriginalContent()
|
||||
if (originalContent !== null) {
|
||||
try {
|
||||
const currentDiskContent = await requestData<FileContent>(
|
||||
browserClient().file.read({ path }),
|
||||
"file.read",
|
||||
)
|
||||
const diskContent = (currentDiskContent as any)?.content
|
||||
|
||||
// If disk content differs from what we originally loaded (agent edit)
|
||||
// AND differs from user's current edits, we have a conflict
|
||||
if (diskContent !== originalContent && diskContent !== content) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return false
|
||||
}
|
||||
// User chose to overwrite, proceed with save
|
||||
}
|
||||
} catch {
|
||||
// If we can't check for conflict, proceed with save
|
||||
}
|
||||
}
|
||||
|
||||
setBrowserSelectedSaving(true)
|
||||
try {
|
||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
||||
setBrowserSelectedDirty(false)
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
|
||||
variant: "success",
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
||||
showToastNotification({
|
||||
message: props.t("instanceShell.rightPanel.toast.saveError"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setBrowserSelectedSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBrowserFileChange = (content: string) => {
|
||||
setBrowserSelectedContent(content)
|
||||
setBrowserSelectedDirty(true)
|
||||
}
|
||||
|
||||
const handleOpenBrowserFileRequest = async (path: string) => {
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (confirmed) {
|
||||
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
||||
if (!saveSuccess) {
|
||||
// Save failed - stay on current file, error toast already shown
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User chose not to save - clear dirty state and discard edits
|
||||
setBrowserSelectedDirty(false)
|
||||
}
|
||||
}
|
||||
await openBrowserFile(path)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "files") return
|
||||
if (browserLoading()) return
|
||||
@@ -565,6 +672,14 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
void loadBrowserEntries(browserPath())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() === "files") return
|
||||
setBrowserSelectedContent(null)
|
||||
setBrowserSelectedLoading(false)
|
||||
setBrowserSelectedError(null)
|
||||
setBrowserSelectedDirty(false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() !== "git-changes") return
|
||||
if (gitStatusLoading()) return
|
||||
@@ -572,6 +687,14 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
void loadGitStatus()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (rightPanelTab() === "git-changes") return
|
||||
setGitSelectedBefore(null)
|
||||
setGitSelectedAfter(null)
|
||||
setGitSelectedLoading(false)
|
||||
setGitSelectedError(null)
|
||||
})
|
||||
|
||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||
setSelectedFile(file)
|
||||
if (closeList) {
|
||||
@@ -607,6 +730,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
}
|
||||
|
||||
const refreshFilesTab = async () => {
|
||||
// Prompt for confirmation if file has unsaved changes
|
||||
if (browserSelectedDirty()) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
|
||||
{
|
||||
variant: "warning",
|
||||
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
||||
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void loadBrowserEntries(browserPath())
|
||||
const selected = browserSelectedPath()
|
||||
if (selected) {
|
||||
@@ -628,6 +767,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
throw new Error("Unsupported file type")
|
||||
}
|
||||
setBrowserSelectedContent(text)
|
||||
setBrowserSelectedOriginalContent(text) // Update original content after refresh
|
||||
setBrowserSelectedDirty(false) // Clear dirty after refresh
|
||||
} catch (error) {
|
||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||
} finally {
|
||||
@@ -647,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
setRightPanelTab("changes")
|
||||
}
|
||||
|
||||
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||
|
||||
createEffect(() => {
|
||||
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||
@@ -738,101 +879,113 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<Show when={rightPanelTab() === "changes"}>
|
||||
<ChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyChangesTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={handleSelectChangesFile}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
listOpen={changesListOpen}
|
||||
onToggleList={toggleChangesList}
|
||||
splitWidth={changesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "git-changes"}>
|
||||
<GitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyGitChangesTab
|
||||
t={props.t}
|
||||
activeSessionId={props.activeSessionId}
|
||||
entries={gitStatusEntries}
|
||||
statusLoading={gitStatusLoading}
|
||||
statusError={gitStatusError}
|
||||
selectedPath={gitSelectedPath}
|
||||
selectedLoading={gitSelectedLoading}
|
||||
selectedError={gitSelectedError}
|
||||
selectedBefore={gitSelectedBefore}
|
||||
selectedAfter={gitSelectedAfter}
|
||||
mostChangedPath={gitMostChangedPath}
|
||||
scopeKey={gitScopeKey}
|
||||
diffViewMode={diffViewMode}
|
||||
diffContextMode={diffContextMode}
|
||||
diffWordWrapMode={diffWordWrapMode}
|
||||
onViewModeChange={setDiffViewMode}
|
||||
onContextModeChange={setDiffContextMode}
|
||||
onWordWrapModeChange={setDiffWordWrapMode}
|
||||
onOpenFile={(path: string) => void openGitFile(path)}
|
||||
onRefresh={() => void refreshGitStatus()}
|
||||
listOpen={gitChangesListOpen}
|
||||
onToggleList={toggleGitList}
|
||||
splitWidth={gitChangesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "files"}>
|
||||
<FilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||
onOpenFile={(path) => void openBrowserFile(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyFilesTab
|
||||
t={props.t}
|
||||
browserPath={browserPath}
|
||||
browserEntries={browserEntries}
|
||||
browserLoading={browserLoading}
|
||||
browserError={browserError}
|
||||
browserSelectedPath={browserSelectedPath}
|
||||
browserSelectedContent={browserSelectedContent}
|
||||
browserSelectedLoading={browserSelectedLoading}
|
||||
browserSelectedError={browserSelectedError}
|
||||
browserSelectedDirty={browserSelectedDirty}
|
||||
browserSelectedSaving={browserSelectedSaving}
|
||||
parentPath={browserParentPath}
|
||||
scopeKey={browserScopeKey}
|
||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||
onRefresh={() => void refreshFilesTab()}
|
||||
onSave={(content: string) => void saveBrowserFile(content)}
|
||||
onContentChange={(content: string) => handleBrowserFileChange(content)}
|
||||
listOpen={filesListOpen}
|
||||
onToggleList={toggleFilesList}
|
||||
splitWidth={filesSplitWidth}
|
||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||
isPhoneLayout={props.isPhoneLayout}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={rightPanelTab() === "status"}>
|
||||
<StatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
<Suspense fallback={<RightPanelTabFallback />}>
|
||||
<LazyStatusTab
|
||||
t={props.t}
|
||||
instanceId={props.instanceId}
|
||||
instance={props.instance}
|
||||
activeSessionId={props.activeSessionId}
|
||||
activeSession={props.activeSession}
|
||||
activeSessionDiffs={props.activeSessionDiffs}
|
||||
latestTodoState={props.latestTodoState}
|
||||
backgroundProcessList={props.backgroundProcessList}
|
||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||
expandedItems={rightPanelExpandedItems}
|
||||
onExpandedItemsChange={handleAccordionChange}
|
||||
onOpenChangesTab={openChangesTabFromStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from "solid-js"
|
||||
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
@@ -14,14 +15,15 @@ interface DiffToolbarProps {
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
||||
const contextModeTitle = () =>
|
||||
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
||||
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
@@ -24,12 +25,13 @@ interface SplitFilePanelProps {
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-header-row">
|
||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||
{props.listOpen ? "Hide files" : "Show files"}
|
||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface ChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={scopeKey()}
|
||||
path={String(file().file || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
after={String((file() as any).after || "")}
|
||||
viewMode={props.diffViewMode()}
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||
import { RefreshCw, Save } from "lucide-solid"
|
||||
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
|
||||
const LazyMonacoFileViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||
)
|
||||
|
||||
interface FilesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -19,13 +21,17 @@ interface FilesTabProps {
|
||||
browserSelectedContent: Accessor<string | null>
|
||||
browserSelectedLoading: Accessor<boolean>
|
||||
browserSelectedError: Accessor<string | null>
|
||||
browserSelectedDirty: Accessor<boolean>
|
||||
browserSelectedSaving: Accessor<boolean>
|
||||
|
||||
parentPath: Accessor<string | null>
|
||||
scopeKey: Accessor<string>
|
||||
|
||||
onLoadEntries: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
onRequestOpenFile: (path: string) => void
|
||||
onRefresh: () => void
|
||||
onSave: (content: string) => void
|
||||
onContentChange: (content: string) => void
|
||||
|
||||
listOpen: Accessor<boolean>
|
||||
onToggleList: () => void
|
||||
@@ -36,6 +42,13 @@ interface FilesTabProps {
|
||||
}
|
||||
|
||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const handleSave = () => {
|
||||
const content = props.browserSelectedContent()
|
||||
if (content !== undefined && content !== null) {
|
||||
props.onSave(content)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
const entriesValue = props.browserEntries()
|
||||
const entries = entriesValue || []
|
||||
@@ -51,8 +64,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||
|
||||
const emptyViewerMessage = () => {
|
||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||
return "Select a file to preview"
|
||||
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
}
|
||||
|
||||
const renderViewer = () => (
|
||||
@@ -77,7 +90,21 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(payload) => (
|
||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoFileViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={payload().path}
|
||||
content={payload().content}
|
||||
onSave={props.onSave}
|
||||
onContentChange={props.onContentChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
@@ -91,7 +118,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -113,7 +140,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Show when={props.browserLoading() && entriesValue === null}>
|
||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||
</Show>
|
||||
|
||||
<For each={sorted}>
|
||||
@@ -125,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
props.onLoadEntries(item.path)
|
||||
return
|
||||
}
|
||||
props.onOpenFile(item.path)
|
||||
props.onRequestOpenFile(item.path)
|
||||
}}
|
||||
title={item.path}
|
||||
>
|
||||
@@ -154,18 +181,29 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
</span>
|
||||
</span>
|
||||
<Show when={props.browserLoading()}>
|
||||
<span>Loading…</span>
|
||||
<span>{props.t("instanceInfo.loading")}</span>
|
||||
</Show>
|
||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
||||
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="files-header-icon-button"
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
@@ -180,7 +218,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Files"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -188,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
return <>{renderContent()}</>
|
||||
}
|
||||
|
||||
export default FilesTab
|
||||
export default FilesTab
|
||||
@@ -1,14 +1,16 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
import { RefreshCw } from "lucide-solid"
|
||||
|
||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||
|
||||
import DiffToolbar from "../components/DiffToolbar"
|
||||
import SplitFilePanel from "../components/SplitFilePanel"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
const LazyMonacoDiffViewer = lazy(() =>
|
||||
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||
)
|
||||
|
||||
interface GitChangesTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
|
||||
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
})
|
||||
|
||||
const emptyViewerMessage = createMemo(() => {
|
||||
if (!hasSession()) return "Select a session to view changes."
|
||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||
const currentEntries = entries()
|
||||
if (currentEntries === null) return "Loading git changes…"
|
||||
if (nonDeleted().length === 0) return "No git changes yet."
|
||||
return "No file selected."
|
||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||
})
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<MonacoDiffViewer
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LazyMonacoDiffViewer
|
||||
scopeKey={props.scopeKey()}
|
||||
path={String(file().path || "")}
|
||||
before={String((file() as any).before || "")}
|
||||
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
contextMode={props.diffContextMode()}
|
||||
wordWrap={props.diffWordWrapMode()}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<div class="file-viewer-empty">
|
||||
<span class="file-viewer-empty-text">Loading…</span>
|
||||
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
</div>
|
||||
<div class="file-list-item-stats">
|
||||
<Show when={item.status === "deleted"}>
|
||||
<span class="text-[10px] text-secondary">deleted</span>
|
||||
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||
</Show>
|
||||
<Show when={item.status !== "deleted"}>
|
||||
<>
|
||||
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
<SplitFilePanel
|
||||
header={
|
||||
<>
|
||||
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||
</span>
|
||||
|
||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onResizeMouseDown={props.onResizeMouseDown}
|
||||
onResizeTouchStart={props.onResizeTouchStart}
|
||||
isPhoneLayout={props.isPhoneLayout()}
|
||||
overlayAriaLabel="Git Changes"
|
||||
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import { Accordion } from "@kobalte/core"
|
||||
import { Tooltip } from "@kobalte/core/tooltip"
|
||||
import Switch from "@suid/material/Switch"
|
||||
|
||||
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||
|
||||
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
|
||||
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
|
||||
|
||||
interface StatusTabProps {
|
||||
t: (key: string, vars?: Record<string, any>) => string
|
||||
@@ -39,6 +41,35 @@ interface StatusTabProps {
|
||||
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||
|
||||
const renderYoloModeSection = () => {
|
||||
const session = props.activeSession()
|
||||
if (!session) {
|
||||
return (
|
||||
<div class="right-panel-empty right-panel-empty--left">
|
||||
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
|
||||
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
|
||||
color="warning"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
|
||||
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderStatusSessionChanges = () => {
|
||||
const sessionId = props.activeSessionId()
|
||||
if (!sessionId || sessionId === "info") {
|
||||
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
}
|
||||
|
||||
const statusSections = [
|
||||
{
|
||||
id: "yolo-mode",
|
||||
labelKey: "instanceShell.rightPanel.sections.yoloMode",
|
||||
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
|
||||
render: renderYoloModeSection,
|
||||
},
|
||||
{
|
||||
id: "session-changes",
|
||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
||||
<For each={statusSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||
<Accordion.Header>
|
||||
<Accordion.Header class="right-panel-accordion-header-row">
|
||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||
<span class="section-left">
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger
|
||||
class="section-info-trigger"
|
||||
aria-label={props.t(section.tooltipKey)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">
|
||||
{props.t(section.tooltipKey)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
|
||||
<Info class="section-info-icon" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
@@ -46,7 +46,9 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||
if (!side) return
|
||||
const startWidth = resizeStartWidth()
|
||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const delta = isRtl ? -rawDelta : rawDelta
|
||||
const nextWidth = clamp(startWidth + delta)
|
||||
applyDrawerWidth(side, nextWidth)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type MarkdownModule = typeof import("../lib/markdown")
|
||||
|
||||
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
||||
|
||||
function loadMarkdownModule(): Promise<MarkdownModule> {
|
||||
if (!markdownModulePromise) {
|
||||
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
||||
markdownModulePromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return markdownModulePromise
|
||||
}
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
function resolvePartCacheId(part: TextPart, text: string): string {
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (partId) {
|
||||
return partId
|
||||
}
|
||||
|
||||
return `anonymous:${hashText(text)}`
|
||||
}
|
||||
|
||||
function decodeHtmlEntitiesLocally(content: string): string {
|
||||
if (!content.includes("&") || typeof document === "undefined") {
|
||||
return content
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.innerHTML = content
|
||||
return textarea.value
|
||||
}
|
||||
|
||||
function escapeHtml(content: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
|
||||
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
||||
}
|
||||
|
||||
function renderFallbackHtml(content: string): string {
|
||||
if (!content) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return escapeHtml(content).replace(/\n/g, "<br />")
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
@@ -31,6 +83,7 @@ interface MarkdownProps {
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
escapeRawHtml?: boolean
|
||||
onRendered?: () => void
|
||||
}
|
||||
|
||||
@@ -38,7 +91,8 @@ export function Markdown(props: MarkdownProps) {
|
||||
const { t } = useI18n()
|
||||
const [html, setHtml] = createSignal("")
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let latestRequestedText = ""
|
||||
let latestRequestKey = ""
|
||||
let cleanupLanguageListener: (() => void) | undefined
|
||||
|
||||
const notifyRendered = () => {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
@@ -47,15 +101,15 @@ export function Markdown(props: MarkdownProps) {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const text = decodeHtmlEntitiesLocally(rawText)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const escapeRawHtml = Boolean(props.escapeRawHtml)
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||
const cacheId = resolvePartCacheId(part, text)
|
||||
const version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
|
||||
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
@@ -63,26 +117,55 @@ export function Markdown(props: MarkdownProps) {
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
const commitCacheEntry = (
|
||||
snapshot: ReturnType<typeof resolved>,
|
||||
renderedHtml: string,
|
||||
options?: { cache?: boolean },
|
||||
) => {
|
||||
const cacheEntry: RenderCache = {
|
||||
text: snapshot.text,
|
||||
html: renderedHtml,
|
||||
theme: snapshot.themeKey,
|
||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||
}
|
||||
setHtml(renderedHtml)
|
||||
if (options?.cache ?? true) {
|
||||
cacheHandle.set(cacheEntry)
|
||||
}
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||
const markdown = await loadMarkdownModule()
|
||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||
suppressHighlight: !snapshot.highlightEnabled,
|
||||
escapeRawHtml: snapshot.escapeRawHtml,
|
||||
})
|
||||
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
|
||||
|
||||
latestRequestedText = text
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, rendered, { cache: shouldCache })
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const snapshot = resolved()
|
||||
latestRequestKey = snapshot.requestKey
|
||||
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
|
||||
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
return cache.theme === themeKey && cache.mode === version
|
||||
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
|
||||
}
|
||||
|
||||
const localCache = part.renderCache
|
||||
const localCache = snapshot.part.renderCache
|
||||
if (localCache && cacheMatches(localCache)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
@@ -96,111 +179,83 @@ export function Markdown(props: MarkdownProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
setHtml(renderFallbackHtml(snapshot.text))
|
||||
notifyRendered()
|
||||
|
||||
if (!highlightEnabled) {
|
||||
try {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
commitCacheEntry(text)
|
||||
if (latestRequestKey === snapshot.requestKey) {
|
||||
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = async (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
const handleClick = async (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||
|
||||
if (copyButton) {
|
||||
e.preventDefault()
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
if (success) {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||
setTimeout(() => {
|
||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!copyButton) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
setMarkdownTheme(themeKey === "dark")
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||
setHtml(rendered)
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
let disposed = false
|
||||
void loadMarkdownModule()
|
||||
.then((markdown) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||
const snapshot = resolved()
|
||||
if (!snapshot.highlightEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
latestRequestKey = snapshot.requestKey
|
||||
void renderSnapshot(snapshot).catch((error) => {
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to load markdown module:", error)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
containerRef?.removeEventListener("click", handleClick)
|
||||
cleanupLanguageListener()
|
||||
cleanupLanguageListener?.()
|
||||
cleanupLanguageListener = undefined
|
||||
})
|
||||
})
|
||||
|
||||
const proseClass = () => "markdown-body"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={proseClass()}
|
||||
class="markdown-body"
|
||||
dir="auto"
|
||||
data-view="markdown"
|
||||
data-part-id={resolved().partId}
|
||||
data-markdown-theme={resolved().themeKey}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
@@ -15,6 +14,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -29,6 +30,12 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
function ToolCallFallback() {
|
||||
return <div class="tool-call tool-call-loading" />
|
||||
}
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
@@ -500,16 +507,18 @@ function ToolCallItem(props: ToolCallItemProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolCall
|
||||
toolCall={resolvedToolPart()}
|
||||
toolCallId={props.partId}
|
||||
messageId={props.messageId}
|
||||
messageVersion={messageVersion()}
|
||||
partVersion={partVersion()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
<Suspense fallback={<ToolCallFallback />}>
|
||||
<LazyToolCall
|
||||
toolCall={resolvedToolPart()}
|
||||
toolCallId={props.partId}
|
||||
messageId={props.messageId}
|
||||
messageVersion={messageVersion()}
|
||||
partVersion={partVersion()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -902,6 +911,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1280,6 +1290,7 @@ interface ReasoningCardProps {
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
@@ -1288,6 +1299,25 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
let pendingRenderNotificationFrame: number | null = null
|
||||
|
||||
const notifyContentRendered = () => {
|
||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
}
|
||||
pendingRenderNotificationFrame = requestAnimationFrame(() => {
|
||||
pendingRenderNotificationFrame = null
|
||||
props.onContentRendered?.()
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingRenderNotificationFrame !== null) {
|
||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
||||
pendingRenderNotificationFrame = null
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
@@ -1356,6 +1386,19 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
|
||||
text: reasoningText,
|
||||
})
|
||||
|
||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
notifyContentRendered()
|
||||
})
|
||||
|
||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||
|
||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||
@@ -1428,6 +1471,20 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions">
|
||||
<Show when={canSpeakReasoning()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void speech.toggle()
|
||||
}}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
@@ -1497,7 +1554,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -294,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
|
||||
text: getRawContent,
|
||||
})
|
||||
|
||||
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
@@ -443,6 +452,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
@@ -503,6 +522,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
@@ -542,7 +571,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
</header>
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
|
||||
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
@@ -550,7 +579,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||
<div class="message-error-block" dir="auto">⚠️ {errorMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isGenerating()}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import ToolCall from "./tool-call"
|
||||
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
@@ -7,6 +6,8 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
interface MessagePartProps {
|
||||
part: ClientPart
|
||||
messageType?: "user" | "assistant"
|
||||
@@ -133,17 +134,19 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||
<div
|
||||
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||
dir="auto"
|
||||
data-role={textContainerRole()}
|
||||
data-part-type="text"
|
||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||
>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
escapeRawHtml={props.messageType === "user"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
@@ -152,12 +155,14 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "tool"}>
|
||||
<ToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||
<LazyToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</Suspense>
|
||||
</Match>
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getPartCharCount } from "../lib/token-utils"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "../stores/instances"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
interface PermissionApprovalModalProps {
|
||||
instanceId: string
|
||||
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<ToolCall
|
||||
toolCall={data().toolPart}
|
||||
toolCallId={data().toolPart.id}
|
||||
messageId={data().messageId}
|
||||
messageVersion={data().messageVersion}
|
||||
partVersion={data().partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={data().sessionId}
|
||||
/>
|
||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||
<LazyToolCall
|
||||
toolCall={data().toolPart}
|
||||
toolCallId={data().toolPart.id}
|
||||
messageId={data().messageId}
|
||||
messageVersion={data().messageVersion}
|
||||
partVersion={data().partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={data().sessionId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||
@@ -13,11 +13,48 @@ import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import { usePromptState } from "./prompt-input/usePromptState"
|
||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import {
|
||||
canUseConversationMode,
|
||||
clearConversationPlaybackForInstance,
|
||||
isConversationModeEnabled,
|
||||
toggleConversationMode,
|
||||
} from "../stores/conversation-speech"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
||||
if (!text || attachments.length === 0) return []
|
||||
|
||||
const usedCounters = new Set<string>()
|
||||
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
||||
const counter = match?.[1]
|
||||
if (counter) usedCounters.add(counter)
|
||||
}
|
||||
|
||||
if (usedCounters.size === 0) return []
|
||||
|
||||
const consumed = new Set<string>()
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment?.id) continue
|
||||
if (attachment?.source?.type !== "text") continue
|
||||
const display = attachment.display
|
||||
if (typeof display !== "string") continue
|
||||
const match = display.match(pastedDisplayCounterRegex)
|
||||
if (!match?.[1]) continue
|
||||
if (usedCounters.has(match[1])) {
|
||||
consumed.add(attachment.id)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(consumed)
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const { t } = useI18n()
|
||||
@@ -246,7 +283,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
commandName.length > 0 &&
|
||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||
|
||||
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
||||
const resolvedPrompt = isKnownSlashCommand
|
||||
? resolvedCommandArgs
|
||||
? `${commandToken} ${resolvedCommandArgs}`
|
||||
: commandToken
|
||||
: resolvePastedPlaceholders(text, currentAttachments)
|
||||
const historyEntry = resolvedPrompt
|
||||
|
||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||
@@ -262,6 +304,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
} else {
|
||||
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
||||
for (const attachmentId of consumedIds) {
|
||||
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||
}
|
||||
syncAttachmentCounters("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
}
|
||||
@@ -281,7 +327,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else if (isKnownSlashCommand) {
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, currentAttachments)
|
||||
}
|
||||
@@ -311,6 +357,19 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function handleClearPrompt() {
|
||||
clearPrompt()
|
||||
clearHistoryDraft()
|
||||
resetHistoryNavigation()
|
||||
setShowPicker(false)
|
||||
setPickerMode("mention")
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
syncAttachmentCounters("")
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function insertBlockContent(block: string) {
|
||||
const textarea = textareaRef
|
||||
const current = prompt()
|
||||
@@ -382,6 +441,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const canClearPrompt = () => prompt().length > 0
|
||||
|
||||
const shellHint = () =>
|
||||
mode() === "shell"
|
||||
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||
@@ -411,9 +472,54 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
})
|
||||
|
||||
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 conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
|
||||
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
|
||||
const canToggleConversationMode = () => canUseConversationMode()
|
||||
const conversationModeButtonTitle = () =>
|
||||
conversationModeEnabled()
|
||||
? t("promptInput.conversationMode.disable.title")
|
||||
: t("promptInput.conversationMode.enable.title")
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
let voiceButtonPressed = false
|
||||
|
||||
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||
voiceButtonPressed = true
|
||||
// Treat a mic press as barge-in: stop any active assistant speech before listening.
|
||||
clearConversationPlaybackForInstance(props.instanceId)
|
||||
|
||||
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 (
|
||||
<div class="prompt-input-container">
|
||||
<div
|
||||
@@ -428,18 +534,20 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Show when={showPicker() && instance()}>
|
||||
<UnifiedPicker
|
||||
open={showPicker()}
|
||||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceId={props.instanceId}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<LazyUnifiedPicker
|
||||
open={showPicker()}
|
||||
mode={pickerMode()}
|
||||
onClose={handlePickerClose}
|
||||
onSelect={handlePickerSelect}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
instanceClient={instance()!.client}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceId={props.instanceId}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
@@ -449,6 +557,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||
dir="auto"
|
||||
placeholder={getPlaceholder()}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
@@ -464,42 +573,111 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-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>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
|
||||
253
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
253
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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"
|
||||
import { isElectronHost } from "../../lib/runtime-env"
|
||||
|
||||
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
|
||||
|
||||
if (isElectronHost()) {
|
||||
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
|
||||
if (granted && !granted.granted) {
|
||||
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) {
|
||||
return []
|
||||
return { recommended: null, hidden: [] }
|
||||
}
|
||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
return splitRemoteAddresses(list)
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
setShowAllAddresses(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -98,6 +100,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
variant: "warning",
|
||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||
dismissible: false,
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
@@ -325,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
@@ -372,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -383,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
@@ -424,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(address) => {
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||
import type { SessionStatus } from "../types/session"
|
||||
import type { SessionThread } from "../stores/session-state"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
|
||||
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
|
||||
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ensureSessionParentExpanded,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
renameSession,
|
||||
sessions as sessionStateSessions,
|
||||
@@ -53,6 +54,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
onCleanup(() => window.clearInterval(timer))
|
||||
})
|
||||
|
||||
const normalizeSessionLabel = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
@@ -157,6 +166,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
if (!confirmed) return
|
||||
@@ -212,6 +222,32 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||
}
|
||||
|
||||
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
|
||||
|
||||
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionReloading(sessionId)) return
|
||||
|
||||
setReloadingSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(sessionId)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
await loadMessages(props.instanceId, sessionId, true)
|
||||
} catch (error) {
|
||||
log.error(`Failed to reload session ${sessionId}:`, error)
|
||||
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
|
||||
} finally {
|
||||
setReloadingSessionIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const closeRenameDialog = () => {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
@@ -285,6 +321,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
variant: "warning",
|
||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -370,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => {
|
||||
const retryState = retry()
|
||||
if (retryState) {
|
||||
const seconds = getRetrySeconds(retryState.next, now())
|
||||
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
|
||||
}
|
||||
switch (formatSessionStatus(status())) {
|
||||
case "working":
|
||||
return t("sessionList.status.working")
|
||||
@@ -383,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
|
||||
const statusText = () =>
|
||||
needsPermission()
|
||||
? t("sessionList.status.needsPermission")
|
||||
: needsQuestion()
|
||||
? t("sessionList.status.needsInput")
|
||||
: statusLabel()
|
||||
const statusTooltip = () => {
|
||||
const retryState = retry()
|
||||
if (!retryState) return undefined
|
||||
return t("sessionList.status.retryTooltip", {
|
||||
message: retryState.message,
|
||||
attempt: String(retryState.attempt),
|
||||
})
|
||||
}
|
||||
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
|
||||
@@ -444,7 +495,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
@@ -469,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||
</span>
|
||||
</Show>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||
{statusText()}
|
||||
</span>
|
||||
@@ -491,6 +542,21 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("sessionList.actions.reload.ariaLabel")}
|
||||
title={t("sessionList.actions.reload.title")}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionReloading(rowProps.sessionId)}
|
||||
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
|
||||
>
|
||||
<RotateCw class="w-3 h-3" />
|
||||
</Show>
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -76,6 +76,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
inputRef = element
|
||||
}}
|
||||
type="text"
|
||||
dir="auto"
|
||||
value={title()}
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
placeholder={t("sessionRenameDialog.input.placeholder")}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user