Compare commits
365 Commits
v0.2.0-dev
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147c9e3e4b | ||
|
|
ab38cdccac | ||
|
|
8168d52295 | ||
|
|
1081bfb276 | ||
|
|
38064b229c | ||
|
|
1a7aefcbae | ||
|
|
e50d9f461a | ||
|
|
d76cf8a3f7 | ||
|
|
c7370fe7bc | ||
|
|
3dfbe2a5b2 | ||
|
|
e30c8b0253 | ||
|
|
df9fc529f9 | ||
|
|
2e9f5b916c | ||
|
|
fd464f349a | ||
|
|
ff6d6f4f76 | ||
|
|
cb2966fb08 | ||
|
|
888e365d72 | ||
|
|
e9241a1b93 | ||
|
|
f01a06d85b | ||
|
|
a68285da68 | ||
|
|
c825ff066e | ||
|
|
f7ded37ea3 | ||
|
|
847faf1214 | ||
|
|
b1691add1c | ||
|
|
3b9a44779a | ||
|
|
62fd88cd3f | ||
|
|
ce2273fe57 | ||
|
|
0eee325777 | ||
|
|
f7c9db44ad | ||
|
|
1fcf89b945 | ||
|
|
f5682ea246 | ||
|
|
fa308696b4 | ||
|
|
ac8dfcc607 | ||
|
|
ac04d5daf7 | ||
|
|
7fe8fee295 | ||
|
|
31940f972f | ||
|
|
5954b332d5 | ||
|
|
eb89dfaf89 | ||
|
|
25bf313338 | ||
|
|
315abf21e6 | ||
|
|
f24e360d78 | ||
|
|
1a6f1fdbae | ||
|
|
e09ce0780e | ||
|
|
95fdad7523 | ||
|
|
06416a9eb3 | ||
|
|
2db62b1d17 | ||
|
|
1377bc6b91 | ||
|
|
fcb5998474 | ||
|
|
c2df32ec8b | ||
|
|
f01149ee9e | ||
|
|
eebfcb5628 | ||
|
|
4571a1dcf9 | ||
|
|
a041e1c6c3 | ||
|
|
abb8a9df19 | ||
|
|
3c450c076a | ||
|
|
4b05e698f8 | ||
|
|
a9524b3e30 | ||
|
|
154c5208b4 | ||
|
|
71479a59a7 | ||
|
|
3606d9aa50 | ||
|
|
3e4d51c9f2 | ||
|
|
2603b1d260 | ||
|
|
94aa469e90 | ||
|
|
dab1e0fa7a | ||
|
|
a14247f049 | ||
|
|
695a890e0a | ||
|
|
402d72d038 | ||
|
|
d32ec73c63 | ||
|
|
d0eac1e610 | ||
|
|
e947691aae | ||
|
|
575f987b8f | ||
|
|
28b66ed0af | ||
|
|
4060c4f60b | ||
|
|
8334e27294 | ||
|
|
722b523f92 | ||
|
|
b4663fb250 | ||
|
|
06be455358 | ||
|
|
450f5bf0b4 | ||
|
|
997d4f4129 | ||
|
|
ff5c698131 | ||
|
|
14497f2082 | ||
|
|
f3e1966b5d | ||
|
|
78592f229e | ||
|
|
c8161669ac | ||
|
|
8ec57da275 | ||
|
|
c00b29145a | ||
|
|
7d2a349e95 | ||
|
|
6c326b18ca | ||
|
|
09229259d1 | ||
|
|
b20bfc34b2 | ||
|
|
4e1f08bfcf | ||
|
|
ef4f8ac45f | ||
|
|
6a7255d9d2 | ||
|
|
f37fcaed3d | ||
|
|
d9fd22c29f | ||
|
|
3fcab5b80a | ||
|
|
4ed2361387 | ||
|
|
75b3699649 | ||
|
|
a6404f25d9 | ||
|
|
7591e5c1c9 | ||
|
|
5e8b3fd5c9 | ||
|
|
20b82496a1 | ||
|
|
542b59940a | ||
|
|
8d5c6b37e9 | ||
|
|
8155fc9956 | ||
|
|
cd4afb5314 | ||
|
|
557c2500c7 | ||
|
|
74f8b6c31f | ||
|
|
da517416a5 | ||
|
|
b8f93bf768 | ||
|
|
0110052758 | ||
|
|
0e0da1a142 | ||
|
|
da3b66a3bd | ||
|
|
088e5f1eea | ||
|
|
0da2e1d7bb | ||
|
|
90c6835ee7 | ||
|
|
92bef8bfb8 | ||
|
|
766be00ded | ||
|
|
ce5eaa1841 | ||
|
|
c323667729 | ||
|
|
67a12d6126 | ||
|
|
bd0cb04b78 | ||
|
|
d3706d2985 | ||
|
|
9769d7a46e | ||
|
|
783fb5c5b2 | ||
|
|
82ff1916b7 | ||
|
|
8204143810 | ||
|
|
e54f80f20e | ||
|
|
54a2917faa | ||
|
|
b72ead1bea | ||
|
|
7996228327 | ||
|
|
7aba3c1221 | ||
|
|
11dedd4446 | ||
|
|
8fcf757c5c | ||
|
|
5cf3c001b5 | ||
|
|
4ae54a1f7b | ||
|
|
81a9c28971 | ||
|
|
235b9338a7 | ||
|
|
642d5e22e6 | ||
|
|
67ff00d83e | ||
|
|
710938eef8 | ||
|
|
dc702b1fb2 | ||
|
|
92d16084db | ||
|
|
9b0e02f66f | ||
|
|
a2e5034c20 | ||
|
|
e3489b22e6 | ||
|
|
cd8948770d | ||
|
|
d4281f1d9c | ||
|
|
49214c60ca | ||
|
|
0a530a257f | ||
|
|
54f269e955 | ||
|
|
91ab2d5e2c | ||
|
|
72773546f5 | ||
|
|
2f58e8a1a9 | ||
|
|
d0cab51eca | ||
|
|
6f04d23b09 | ||
|
|
3e72b83393 | ||
|
|
87da8ee9f8 | ||
|
|
ec5c5c8c0f | ||
|
|
b9394fb467 | ||
|
|
de432106e5 | ||
|
|
1fbf51b7ae | ||
|
|
864d665049 | ||
|
|
c4a9c032a3 | ||
|
|
3373e23a41 | ||
|
|
b0650a283e | ||
|
|
52149f5543 | ||
|
|
2e5a904034 | ||
|
|
f1ad1400a7 | ||
|
|
bbd28404ff | ||
|
|
04f6e362b9 | ||
|
|
0b9cce6f86 | ||
|
|
d68cb6b1b8 | ||
|
|
e345dc1262 | ||
|
|
2b27790a81 | ||
|
|
2514fa94b4 | ||
|
|
522910ff64 | ||
|
|
971abe24d7 | ||
|
|
49143bd049 | ||
|
|
df52ed3035 | ||
|
|
617aac8fd8 | ||
|
|
6e82ecc97e | ||
|
|
636a19fc50 | ||
|
|
97f78bb337 | ||
|
|
0ca39d2fb0 | ||
|
|
aad1337111 | ||
|
|
6d7bc813ed | ||
|
|
1a0dd21540 | ||
|
|
7cf9c35375 | ||
|
|
f1c32253af | ||
|
|
4a8d13e2cd | ||
|
|
b0fd63ead5 | ||
|
|
94cb741c7f | ||
|
|
976430d61c | ||
|
|
8a8555d591 | ||
|
|
57c1605242 | ||
|
|
cfbd0bdffa | ||
|
|
58efb8bc3e | ||
|
|
b35bfe63c0 | ||
|
|
d7b5f53d59 | ||
|
|
168b782006 | ||
|
|
9e0fbd185d | ||
|
|
11be314f63 | ||
|
|
36ee301ef2 | ||
|
|
d6dd06b7d1 | ||
|
|
6a16dd8f98 | ||
|
|
78338f33c1 | ||
|
|
8c72d279df | ||
|
|
a9500276c8 | ||
|
|
f9ec757c64 | ||
|
|
f4c9385661 | ||
|
|
6ba50cadd2 | ||
|
|
8d5169cb39 | ||
|
|
fe8b4a9acd | ||
|
|
831e59cd77 | ||
|
|
7fde8afcf0 | ||
|
|
d07c2ec4a9 | ||
|
|
4306147990 | ||
|
|
c614da3e3c | ||
|
|
73b59d8266 | ||
|
|
a2d8ea0dfd | ||
|
|
52ee196103 | ||
|
|
1a1aee8f91 | ||
|
|
5384ff8e80 | ||
|
|
6d5836ce1f | ||
|
|
d3dc170e02 | ||
|
|
983c8cc4a3 | ||
|
|
757c587b17 | ||
|
|
5f9cf397b9 | ||
|
|
78ab17d148 | ||
|
|
e91923ad99 | ||
|
|
fd23ea54b6 | ||
|
|
1e7969eaba | ||
|
|
77bfe41a8e | ||
|
|
6d134e4dec | ||
|
|
9423326193 | ||
|
|
c5011e4ece | ||
|
|
66c270151a | ||
|
|
5ce41217e9 | ||
|
|
1e4d949d35 | ||
|
|
6bb9e8e414 | ||
|
|
1efc49b67b | ||
|
|
f0ed98222a | ||
|
|
ddd8ce341a | ||
|
|
b7721ba3e7 | ||
|
|
0554018980 | ||
|
|
ca18942bfd | ||
|
|
c9c1f69b82 | ||
|
|
aa0c31fa1e | ||
|
|
96b88dbcdc | ||
|
|
50676416ed | ||
|
|
f633d75005 | ||
|
|
4085f6d6b9 | ||
|
|
ae288833e1 | ||
|
|
f16e244265 | ||
|
|
b6e43c899b | ||
|
|
9fa436b0b8 | ||
|
|
ccd65fbc74 | ||
|
|
daa7e3a6d1 | ||
|
|
ff356ac5ea | ||
|
|
d68b92ff38 | ||
|
|
940216d98b | ||
|
|
69cd3cf545 | ||
|
|
042a45db0d | ||
|
|
cc45c16d73 | ||
|
|
91fb351a63 | ||
|
|
d9b149a7cb | ||
|
|
222a467a19 | ||
|
|
18513939f7 | ||
|
|
c123714271 | ||
|
|
5c82a2d653 | ||
|
|
435881529e | ||
|
|
700342670c | ||
|
|
2f40f5eedf | ||
|
|
54905c5626 | ||
|
|
1bf1a4761d | ||
|
|
755695a35a | ||
|
|
6a9a442948 | ||
|
|
3db9b0f673 | ||
|
|
4e0e5dcdca | ||
|
|
fad2809299 | ||
|
|
c77bfc2ee7 | ||
|
|
f1fa28dd2c | ||
|
|
91ace25333 | ||
|
|
b54db28fb1 | ||
|
|
f13feb3062 | ||
|
|
4622bdc7ea | ||
|
|
919127b6d9 | ||
|
|
27cd4515cd | ||
|
|
93a5c16cab | ||
|
|
16b76385e2 | ||
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 | ||
|
|
3688be06ee | ||
|
|
8b2be441fc | ||
|
|
b2493a3a53 | ||
|
|
4eb3dbf492 | ||
|
|
adbe0399b2 | ||
|
|
e2461661f7 | ||
|
|
cc012094b4 | ||
|
|
968a6f3cab | ||
|
|
f0d8634a83 | ||
|
|
9f862d5afc | ||
|
|
08f3d75015 | ||
|
|
d9adab3022 | ||
|
|
9019f7622e | ||
|
|
631b5002e7 | ||
|
|
b1987008c7 | ||
|
|
3338109d51 | ||
|
|
c0616df704 | ||
|
|
ca4030e86e | ||
|
|
0a2d57624c | ||
|
|
dbbed94381 | ||
|
|
a088b948b4 | ||
|
|
87faa32c7c | ||
|
|
873be2d6c1 | ||
|
|
5fafed31d0 | ||
|
|
a763831b83 | ||
|
|
076aa4ff9a | ||
|
|
31cbb9cc53 | ||
|
|
ee6db23b14 | ||
|
|
9fe3dcdc6d | ||
|
|
ea644a7d0f | ||
|
|
2f7ddd57dd | ||
|
|
f2f108c14e | ||
|
|
731885f54a | ||
|
|
3c11a1bfcb | ||
|
|
166dd3f719 | ||
|
|
123da12468 | ||
|
|
2a7cdbae42 | ||
|
|
88952a140a | ||
|
|
b64ea411e6 | ||
|
|
44b2bb1b68 | ||
|
|
efabf83a26 | ||
|
|
ac540a18f2 | ||
|
|
4bad384ca0 | ||
|
|
0eb00901b9 |
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or regression in CodeNomad
|
||||
labels:
|
||||
- bug
|
||||
title: "[Bug]: "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: App Variant
|
||||
description: Which build are you running when this issue appears?
|
||||
multiple: false
|
||||
options:
|
||||
- Electron
|
||||
- Tauri
|
||||
- Server CLI
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Operating System & Version
|
||||
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
|
||||
placeholder: macOS 15.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: Briefly describe what is happening.
|
||||
placeholder: A quick one sentence problem statement
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: List the steps needed to reproduce the problem.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what you expected to happen instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Screenshots
|
||||
description: Attach relevant logs, stack traces, or screenshots if available.
|
||||
placeholder: Paste logs here or drag-and-drop files onto the issue.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
519
.github/workflows/build-and-upload.yml
vendored
519
.github/workflows/build-and-upload.yml
vendored
@@ -4,27 +4,40 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to apply to workspace packages"
|
||||
required: true
|
||||
description: "Version to apply to workspace packages (release builds)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
tag:
|
||||
description: "Git tag to upload assets to"
|
||||
required: true
|
||||
description: "Git tag to upload assets to (release builds)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release name (unused here, for context)"
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
upload:
|
||||
description: "Upload built artifacts to the GitHub release"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
set_versions:
|
||||
description: "Run npm version to set workspace versions"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# Permissions are intentionally omitted here so callers can choose
|
||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
@@ -40,34 +53,31 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS binaries
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.dmg|*.zip)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
@@ -83,29 +93,30 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows binaries
|
||||
- name: Build Windows binaries (Electron)
|
||||
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
|
||||
$_.Name -match '\\.(exe|zip)$'
|
||||
} | ForEach-Object {
|
||||
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
@@ -120,35 +131,455 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux binaries (Electron)
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build macOS bundle (Tauri)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos-arm64:
|
||||
runs-on: macos-26
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build macOS bundle (Tauri, arm64)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
shell: bash
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Windows bundle (Tauri)
|
||||
shell: bash
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
if: ${{ inputs.upload }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
$artifactDir = "packages/tauri-app/release-tauri"
|
||||
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
||||
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
||||
if ($null -ne $exe) {
|
||||
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
}
|
||||
|
||||
build-tauri-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Prebuild (Tauri)
|
||||
run: npm run prebuild --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Ensure tauri native binary
|
||||
working-directory: packages/tauri-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||
fi
|
||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||
done
|
||||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Linux bundle (Tauri)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
if: ${{ inputs.upload }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
|
||||
find_one() {
|
||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||
}
|
||||
|
||||
appimage=$(find_one "*.AppImage")
|
||||
deb=$(find_one "*.deb")
|
||||
rpm=$(find_one "*.rpm")
|
||||
|
||||
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
|
||||
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-linux-arm64:
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo dpkg --add-architecture arm64
|
||||
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||
EOF
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
libglib2.0-dev:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 \
|
||||
libsoup-3.0-dev:arm64 \
|
||||
libayatana-appindicator3-dev:arm64 \
|
||||
librsvg2-dev:arm64
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||
|
||||
- name: Build Linux binaries
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
- name: Build Linux bundle (Tauri arm64)
|
||||
env:
|
||||
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Upload release assets
|
||||
- name: Package Tauri artifacts (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
|
||||
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||
if [ -n "$first_artifact" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
|
||||
elif [ -f "$fallback_bin" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
|
||||
else
|
||||
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Tauri release assets (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.AppImage|*.deb|*.tar.gz)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
|
||||
build-linux-rpm:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
@@ -170,10 +601,11 @@ jobs:
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Set workspace versions
|
||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
@@ -182,9 +614,12 @@ jobs:
|
||||
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload RPM release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.rpm; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
86
.github/workflows/dev-release.yml
vendored
86
.github/workflows/dev-release.yml
vendored
@@ -1,88 +1,18 @@
|
||||
name: Dev Release
|
||||
name: Dev CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare-dev:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute dev versions
|
||||
id: versions
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
DEV_VERSION="${BASE_VERSION}-dev"
|
||||
TAG="v${DEV_VERSION}"
|
||||
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-dev
|
||||
dev-ci:
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-dev.outputs.version }}
|
||||
tag: ${{ needs.prepare-dev.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-dev.outputs.release_name }}
|
||||
upload: false
|
||||
set_versions: false
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs: build-and-upload
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.prepare-dev.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Publish server package to dev tag
|
||||
run: npm publish --workspace @neuralnomads/codenomad --access public --tag dev
|
||||
|
||||
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Manual NPM Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.2.0-dev)"
|
||||
required: false
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_INPUT="${{ inputs.version }}"
|
||||
if [ -z "$VERSION_INPUT" ]; then
|
||||
VERSION_INPUT=$(node -p "require('./package.json').version")
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
run: |
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
92
.github/workflows/release.yml
vendored
92
.github/workflows/release.yml
vendored
@@ -6,96 +6,12 @@ on:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
tag: ${{ steps.ensure_tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Read version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Ensure git tag
|
||||
id: ensure_tag
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
TAG="v${VERSION}"
|
||||
git fetch --tags
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag $TAG already exists"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Created tag $TAG"
|
||||
fi
|
||||
|
||||
- name: Ensure GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.ensure_tag.outputs.tag }}
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
release:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: latest
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs: build-and-upload
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Publish server package
|
||||
run: npm publish --workspace @neuralnomads/codenomad --access public --tag latest
|
||||
|
||||
80
.github/workflows/reusable-release.yml
vendored
Normal file
80
.github/workflows/reusable-release.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Reusable Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag to publish under"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute release versions
|
||||
id: versions
|
||||
env:
|
||||
VERSION_SUFFIX: ${{ inputs.version_suffix }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
|
||||
TAG="v${VERSION}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
secrets: inherit
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ release/
|
||||
.vite/
|
||||
.electron-vite/
|
||||
out/
|
||||
.dir-locals.el
|
||||
.opencode/bashOutputs/
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-4.6
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
|
||||
53
README.md
53
README.md
@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
|
||||

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

|
||||

|
||||
_Rich media previews for images and assets._
|
||||
|
||||

|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
@@ -26,11 +26,17 @@ _Browser support via CodeNomad Server._
|
||||
Choose the way that fits your workflow:
|
||||
|
||||
### 🖥️ Desktop App (Recommended)
|
||||
The best experience. A native application with global shortcuts, deeper system integration, and a dedicated window.
|
||||
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
|
||||
|
||||
- **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.
|
||||
|
||||
### 🦀 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.
|
||||
|
||||
### 💻 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.
|
||||
|
||||
@@ -38,6 +44,12 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
For dev version
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad@dev --launch
|
||||
```
|
||||
|
||||
This command starts the server and opens the web client in your default browser.
|
||||
|
||||
## Highlights
|
||||
@@ -52,6 +64,41 @@ This command starts the server and opens the web client in your default browser.
|
||||
- **[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.
|
||||
|
||||
## 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`:
|
||||
|
||||
```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:
|
||||
|
||||
@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||
│ │ │ - instances[] │ │ │
|
||||
│ │ │ - sessions[] per instance │ │ │
|
||||
│ │ │ - messages[] per session │ │ │
|
||||
│ │ │ - normalized message store per session │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageStream │ │ │
|
||||
│ │ │ - MessageSection │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
|
||||
82
dev-docs/solidjs-llms.txt
Normal file
82
dev-docs/solidjs-llms.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
# SolidJS Documentation
|
||||
|
||||
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
|
||||
|
||||
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
|
||||
|
||||
Key principles:
|
||||
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
|
||||
- Compile-time optimization: JSX transforms into efficient DOM operations
|
||||
- Unidirectional data flow: Props are read-only, promoting predictable state management
|
||||
- Component lifecycle: Components run once, with reactive primitives handling updates
|
||||
|
||||
**Use your web fetch tool on any of the following links to understand the relevant concept**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
|
||||
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
|
||||
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
|
||||
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
|
||||
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
|
||||
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
|
||||
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
|
||||
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
|
||||
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
|
||||
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
|
||||
|
||||
## Component APIs
|
||||
|
||||
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
|
||||
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
|
||||
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
|
||||
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
|
||||
|
||||
## Control Flow
|
||||
|
||||
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
|
||||
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
|
||||
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
|
||||
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
|
||||
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
|
||||
|
||||
## Derived Values
|
||||
|
||||
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
|
||||
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
|
||||
|
||||
## State Management
|
||||
|
||||
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
|
||||
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
|
||||
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
|
||||
|
||||
## Routing
|
||||
|
||||
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
|
||||
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
|
||||
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
|
||||
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
|
||||
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
|
||||
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
|
||||
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
|
||||
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
|
||||
|
||||
## Optional
|
||||
|
||||
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
|
||||
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
|
||||
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
|
||||
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options
|
||||
@@ -49,7 +49,7 @@ packages/opencode-client/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream.tsx # Messages display
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
@@ -153,16 +153,24 @@ interface Session {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
messages: Message[]
|
||||
status: SessionStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
|
||||
|
Before Width: | Height: | Size: 845 KiB After Width: | Height: | Size: 845 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
1768
package-lock.json
generated
1768
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
"packages/server",
|
||||
"packages/ui",
|
||||
"packages/electron-app",
|
||||
"packages/tauri-app"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
@@ -23,5 +26,8 @@
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
@@ -52,9 +53,19 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
sourcemap: true,
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: uiRendererEntry,
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
output: {
|
||||
compact: false,
|
||||
minifyInternalExports: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
interface DialogOpenRequest {
|
||||
mode: "directory" | "file"
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
filters?: Array<{ name?: string; extensions: string[] }>
|
||||
}
|
||||
|
||||
interface DialogOpenResult {
|
||||
canceled: boolean
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("log", (entry: CliLogEntry) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:log", entry)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("error", (error: Error) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||
@@ -27,4 +33,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
})
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
|
||||
ipcMain.handle("cli:restart", async () => {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.stop()
|
||||
return cliManager.start({ dev: devMode })
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
const filters = request.filters?.map((filter) => ({
|
||||
name: filter.name ?? "Files",
|
||||
extensions: filter.extensions,
|
||||
}))
|
||||
|
||||
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||
const dialogOptions: OpenDialogOptions = {
|
||||
title: request.title,
|
||||
defaultPath: request.defaultPath,
|
||||
properties,
|
||||
filters,
|
||||
}
|
||||
const result = windowTarget
|
||||
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -30,22 +30,113 @@ function getIconPath() {
|
||||
return join(mainDirname, "../resources/icon.png")
|
||||
}
|
||||
|
||||
function getLoadingHtmlPath() {
|
||||
type LoadingTarget =
|
||||
| { type: "url"; source: string }
|
||||
| { type: "file"; source: string }
|
||||
|
||||
function resolveDevLoadingUrl(): string | null {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "loading.html")
|
||||
return null
|
||||
}
|
||||
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||
if (!devBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
const distResources = join(mainDirname, "../resources/loading.html")
|
||||
if (existsSync(distResources)) {
|
||||
return distResources
|
||||
try {
|
||||
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||
return new URL("loading.html", normalized).toString()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoadingTarget(): LoadingTarget {
|
||||
const devUrl = resolveDevLoadingUrl()
|
||||
if (devUrl) {
|
||||
return { type: "url", source: devUrl }
|
||||
}
|
||||
const filePath = resolveLoadingFilePath()
|
||||
return { type: "file", source: filePath }
|
||||
}
|
||||
|
||||
function resolveLoadingFilePath() {
|
||||
const candidates = [
|
||||
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||
join(mainDirname, "../dist/renderer/loading.html"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const devResources = join(mainDirname, "../electron/resources/loading.html")
|
||||
if (existsSync(devResources)) {
|
||||
return devResources
|
||||
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||
}
|
||||
|
||||
function loadLoadingScreen(window: BrowserWindow) {
|
||||
const target = resolveLoadingTarget()
|
||||
const loader =
|
||||
target.type === "url"
|
||||
? window.loadURL(target.source)
|
||||
: window.loadFile(target.source)
|
||||
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
const origins = new Set<string>()
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to parse origin for", candidate, error)
|
||||
}
|
||||
}
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setupNavigationGuards(window: BrowserWindow) {
|
||||
const handleExternal = (url: string) => {
|
||||
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
||||
}
|
||||
|
||||
return join(process.cwd(), "electron/resources/loading.html")
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
@@ -112,14 +203,15 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
const loadingHtml = getLoadingHtmlPath()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
||||
loadLoadingScreen(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
@@ -156,8 +248,7 @@ function showLoadingScreen(force = false) {
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
const loadingHtml = getLoadingHtmlPath()
|
||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
||||
loadLoadingScreen(mainWindow)
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
@@ -10,6 +11,7 @@ const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
@@ -34,6 +36,36 @@ interface CliEntryResolution {
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
return path.join(os.homedir(), target.slice(2))
|
||||
}
|
||||
return path.resolve(target)
|
||||
}
|
||||
|
||||
function resolveHostForMode(mode: ListeningMode): string {
|
||||
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
||||
}
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||
if (!existsSync(configPath)) return "local"
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to read listening mode from config", error)
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
@@ -58,10 +90,12 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const args = this.buildCliArgs(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}`,
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
@@ -116,7 +150,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 15000)
|
||||
}, 60000)
|
||||
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
@@ -158,6 +192,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
@@ -232,8 +270,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions): string[] {
|
||||
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
@@ -263,47 +301,32 @@ export class CliProcessManager extends EventEmitter {
|
||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||
if (options.dev) {
|
||||
const tsxPath = this.resolveTsx()
|
||||
const sourceCandidates = [
|
||||
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
|
||||
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
|
||||
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
|
||||
]
|
||||
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
|
||||
if (tsxPath && sourceEntry) {
|
||||
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
if (!tsxPath) {
|
||||
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||
}
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const dist = this.tryResolveDist()
|
||||
if (dist) {
|
||||
return { entry: dist, runner: "node" }
|
||||
}
|
||||
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
}
|
||||
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
try {
|
||||
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
|
||||
if (resolved && existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private tryResolveDist(): string | null {
|
||||
const candidates: Array<string | (() => string)> = [
|
||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
|
||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
|
||||
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
||||
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
|
||||
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
|
||||
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
||||
() => nodeRequire.resolve("tsx/cli"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||
]
|
||||
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
@@ -314,7 +337,28 @@ export class CliProcessManager extends EventEmitter {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveDevEntry(): string {
|
||||
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function setupStorageIPC() {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -5,15 +5,13 @@ const electronAPI = {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
},
|
||||
onCliLog: (callback) => {
|
||||
ipcRenderer.on("cli:log", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:log")
|
||||
},
|
||||
onCliError: (callback) => {
|
||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background-color: #1a1a1a;
|
||||
color: #cfd4dc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.title {
|
||||
font-size: 2.7rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #f4f6fb;
|
||||
}
|
||||
.loading-card {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 22px;
|
||||
border-radius: 18px;
|
||||
background: #151a23;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.loading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
font-size: 0.95rem;
|
||||
color: #cfd4dc;
|
||||
}
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: #6ce3ff;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
.phrase-controls {
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: #8f96a9;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.phrase-controls button {
|
||||
color: #8fb5ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
.title {
|
||||
font-size: 2.7rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #f4f6fb;
|
||||
}
|
||||
.loading-card {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 22px;
|
||||
border-radius: 18px;
|
||||
background: #0f1421;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 2.7rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #aeb3c4;
|
||||
}
|
||||
.loading-card {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(13, 16, 24, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.loading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
font-size: 0.95rem;
|
||||
color: #cad0dd;
|
||||
}
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: #6ce3ff;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper" role="status" aria-live="polite">
|
||||
<img src="./icon.png" alt="CodeNomad" class="logo" />
|
||||
<div>
|
||||
<h1 class="title">CodeNomad</h1>
|
||||
</div>
|
||||
<div class="loading-card">
|
||||
<div class="loading-row">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<span id="loading-phrase">Warming up the AI neurons…</span>
|
||||
</div>
|
||||
<div class="phrase-controls">
|
||||
<button id="phrase-toggle" type="button">Show another</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const phrases = [
|
||||
"Warming up the AI neurons…",
|
||||
"Convincing the AI to stop daydreaming…",
|
||||
"Polishing the AI’s code goggles…",
|
||||
"Asking the AI to stop reorganizing your files…",
|
||||
"Feeding the AI additional coffee…",
|
||||
"Teaching the AI not to delete node_modules (again)…",
|
||||
"Telling the AI to act natural before you arrive…",
|
||||
"Asking the AI to please stop rewriting history…",
|
||||
"Letting the AI stretch before its coding sprint…",
|
||||
"Persuading the AI to give you keyboard control…"
|
||||
]
|
||||
|
||||
const phraseEl = document.getElementById("loading-phrase")
|
||||
const button = document.getElementById("phrase-toggle")
|
||||
|
||||
function pickPhrase() {
|
||||
const next = phrases[Math.floor(Math.random() * phrases.length)]
|
||||
phraseEl.textContent = next
|
||||
}
|
||||
|
||||
pickPhrase()
|
||||
button?.addEventListener("click", pickPhrase)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
@@ -65,42 +65,55 @@
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": "",
|
||||
"filter": ["!icon.icns", "!icon.ico", "!icon.png"]
|
||||
"filter": [
|
||||
"!icon.icns",
|
||||
"!icon.ico"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/dist/opencode-config",
|
||||
"to": "opencode-config"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64", "universal"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64", "universal"]
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{ "x": 130, "y": 220 },
|
||||
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
@@ -112,23 +125,14 @@
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": ["x64", "arm64"]
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
@@ -16,8 +16,8 @@ const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64", "--universal"],
|
||||
description: "macOS (Intel, Apple Silicon, Universal)",
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
description: "macOS (Intel & Apple Silicon)",
|
||||
},
|
||||
"mac-x64": {
|
||||
args: ["--mac", "--x64"],
|
||||
@@ -55,12 +55,22 @@ const platforms = {
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
|
||||
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
|
||||
|
||||
const binPaths = [
|
||||
join(nodeModulesPath, ".bin"),
|
||||
join(workspaceNodeModulesPath, ".bin"),
|
||||
]
|
||||
|
||||
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
|
||||
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
env,
|
||||
}
|
||||
|
||||
const child = spawn(command, args, spawnOptions)
|
||||
|
||||
32
packages/opencode-config/README.md
Normal file
32
packages/opencode-config/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# opencode-config
|
||||
|
||||
## TLDR
|
||||
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
|
||||
|
||||
## What it is
|
||||
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
|
||||
|
||||
## How it works
|
||||
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
|
||||
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
|
||||
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
|
||||
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
|
||||
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
|
||||
|
||||
## Expectations
|
||||
- Local-only bridge (no auth/token yet).
|
||||
- Plugin must fail startup if it cannot connect after 3 retries.
|
||||
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
|
||||
- Keep event shapes small and explicit; use `type` + `properties` only.
|
||||
|
||||
## Ideas
|
||||
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
|
||||
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
|
||||
- Promote stable event shapes and version tags once the protocol settles.
|
||||
|
||||
## Pointers
|
||||
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
|
||||
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
|
||||
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
|
||||
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
|
||||
- Workspace env injection: `packages/server/src/workspaces/manager.ts`
|
||||
3
packages/opencode-config/opencode.jsonc
Normal file
3
packages/opencode-config/opencode.jsonc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
8
packages/opencode-config/package.json
Normal file
8
packages/opencode-config/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@codenomad/opencode-config",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.8"
|
||||
}
|
||||
}
|
||||
32
packages/opencode-config/plugin/codenomad.ts
Normal file
32
packages/opencode-config/plugin/codenomad.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
|
||||
|
||||
await client.startEvents((event) => {
|
||||
if (event.type === "codenomad.ping") {
|
||||
void client.postEvent({
|
||||
type: "codenomad.pong",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
309
packages/opencode-config/plugin/lib/background-process.ts
Normal file
309
packages/opencode-config/plugin/lib/background-process.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
|
||||
type BackgroundProcess = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
status: "running" | "stopped" | "error"
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
|
||||
type ParsedCommand = {
|
||||
head: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
|
||||
const base = config.baseUrl.replace(/\/+$/, "")
|
||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
return {
|
||||
run_background_process: tool({
|
||||
description:
|
||||
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
|
||||
args: {
|
||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||
},
|
||||
async execute(args) {
|
||||
assertCommandWithinBase(args.command, options.baseDir)
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
||||
})
|
||||
|
||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||
},
|
||||
}),
|
||||
list_background_processes: tool({
|
||||
description: "List background processes running for this workspace.",
|
||||
args: {},
|
||||
async execute() {
|
||||
const response = await request<{ processes: BackgroundProcess[] }>("")
|
||||
if (response.processes.length === 0) {
|
||||
return "No background processes running."
|
||||
}
|
||||
|
||||
return response.processes
|
||||
.map((process) => {
|
||||
const status = process.status === "running" ? "running" : process.status
|
||||
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
|
||||
const size =
|
||||
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
|
||||
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
|
||||
})
|
||||
.join("\n")
|
||||
},
|
||||
}),
|
||||
read_background_process_output: tool({
|
||||
description: "Read output from a background process. Use full, grep, head, or tail.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
method: tool.schema
|
||||
.enum(["full", "grep", "head", "tail"])
|
||||
.default("full")
|
||||
.describe("Method to read output"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
|
||||
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
|
||||
},
|
||||
async execute(args) {
|
||||
if (args.method === "grep" && !args.pattern) {
|
||||
return "Pattern is required for grep method."
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ method: args.method })
|
||||
if (args.pattern) {
|
||||
params.set("pattern", args.pattern)
|
||||
}
|
||||
if (args.lines) {
|
||||
params.set("lines", String(args.lines))
|
||||
}
|
||||
|
||||
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
|
||||
`/${args.id}/output?${params.toString()}`,
|
||||
)
|
||||
|
||||
const header = response.truncated
|
||||
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
|
||||
return `${header}\n\n${response.content}`
|
||||
},
|
||||
}),
|
||||
stop_background_process: tool({
|
||||
description: "Stop a background process (SIGTERM) but keep its output and entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
|
||||
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
|
||||
},
|
||||
}),
|
||||
terminate_background_process: tool({
|
||||
description: "Terminate a background process and delete its output + entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
await request<void>(`/${args.id}/terminate`, { method: "POST" })
|
||||
return `Terminated background process ${args.id} and removed its output.`
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
|
||||
const EXPANSION_CHARS = /[~*$?\[\]`$]/
|
||||
|
||||
function assertCommandWithinBase(command: string, baseDir: string) {
|
||||
const normalizedBase = path.resolve(baseDir)
|
||||
const commands = splitCommands(command)
|
||||
|
||||
for (const item of commands) {
|
||||
if (!FILE_COMMANDS.has(item.head)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const arg of item.args) {
|
||||
if (!arg) continue
|
||||
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
|
||||
|
||||
const literalArg = unquote(arg)
|
||||
if (EXPANSION_CHARS.test(literalArg)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
|
||||
if (!isWithinBase(normalizedBase, resolved)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitCommands(command: string): ParsedCommand[] {
|
||||
const tokens = tokenize(command)
|
||||
const commands: ParsedCommand[] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isSeparator(token)) {
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
current = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.push(token)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ""
|
||||
let quote: "'" | '"' | null = null
|
||||
let escape = false
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current)
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index]
|
||||
|
||||
if (escape) {
|
||||
current += char
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\" && quote !== "'") {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === " " || char === "\n" || char === "\t") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "|" || char === "&" || char === ";") {
|
||||
flush()
|
||||
const next = input[index + 1]
|
||||
if ((char === "|" || char === "&") && next === char) {
|
||||
tokens.push(char + next)
|
||||
index += 1
|
||||
} else {
|
||||
tokens.push(char)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isSeparator(token: string) {
|
||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
||||
}
|
||||
|
||||
function unquote(value: string) {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isWithinBase(baseDir: string, target: string) {
|
||||
const relative = path.relative(baseDir, target)
|
||||
if (!relative) return true
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
165
packages/opencode-config/plugin/lib/client.ts
Normal file
165
packages/opencode-config/plugin/lib/client.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||
return {
|
||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
||||
|
||||
// Fail plugin startup if we cannot establish the initial connection.
|
||||
const initialBody = await connectWithRetries(url, 3)
|
||||
|
||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||
void consumeWithReconnect(url, onEvent, initialBody)
|
||||
}
|
||||
|
||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await delay(500 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
||||
}
|
||||
|
||||
async function consumeWithReconnect(
|
||||
url: string,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
initialBody: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
let consecutiveFailures = 0
|
||||
let body: ReadableStream<Uint8Array> | null = initialBody
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (!body) {
|
||||
body = await connectWithRetries(url, 3)
|
||||
}
|
||||
|
||||
await consumeSseBody(body, onEvent)
|
||||
body = null
|
||||
consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
body = null
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
|
||||
}
|
||||
await delay(500 * consecutiveFailures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
const event = parseSseChunk(chunk)
|
||||
if (event) {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("SSE stream ended")
|
||||
}
|
||||
|
||||
function parseSseChunk(chunk: string): PluginEvent | null {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) continue
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return null
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload)
|
||||
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
|
||||
return null
|
||||
}
|
||||
return parsed as PluginEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
61
packages/server/scripts/copy-opencode-config.mjs
Normal file
61
packages/server/scripts/copy-opencode-config.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
||||
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
||||
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
|
||||
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
|
||||
|
||||
const npmArgs = [
|
||||
"install",
|
||||
"--prefix",
|
||||
sourceDir,
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
"--package-lock=false",
|
||||
"--workspaces=false",
|
||||
]
|
||||
|
||||
const env = { ...process.env, npm_config_workspaces: "false" }
|
||||
|
||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||
const result = npmCli
|
||||
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
|
||||
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
console.error("[copy-opencode-config] npm install failed to start", result.error)
|
||||
}
|
||||
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
// npm can create a self-referential link for scoped packages on Windows.
|
||||
// That link causes recursive copies (ELOOP) during bundling.
|
||||
rmSync(selfLinkDir, { recursive: true, force: true })
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
@@ -111,6 +111,14 @@ export interface InstanceData {
|
||||
agentModelSelections: AgentModelSelection
|
||||
}
|
||||
|
||||
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||
|
||||
export interface InstanceStreamEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
id: string
|
||||
path: string
|
||||
@@ -157,6 +165,9 @@ export type WorkspaceEventType =
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
| "app.releaseAvailable"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
@@ -167,16 +178,72 @@ export type WorkspaceEventPayload =
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
version: string
|
||||
tag: string
|
||||
url: string
|
||||
channel: "stable" | "dev"
|
||||
publishedAt?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
/** Reachable addresses for this server, external first. */
|
||||
addresses: NetworkAddress[]
|
||||
/** Optional metadata about the most recent public release. */
|
||||
latestRelease?: LatestReleaseInfo
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
title: string
|
||||
command: string
|
||||
cwd: string
|
||||
status: BackgroundProcessStatus
|
||||
pid?: number
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
processes: BackgroundProcess[]
|
||||
}
|
||||
|
||||
export interface BackgroundProcessOutputResponse {
|
||||
id: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
484
packages/server/src/background-processes/manager.ts
Normal file
484
packages/server/src/background-processes/manager.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
||||
|
||||
const ROOT_DIR = ".codenomad/background_processes"
|
||||
const INDEX_FILE = "index.json"
|
||||
const OUTPUT_FILE = "output.txt"
|
||||
const STOP_TIMEOUT_MS = 2000
|
||||
const EXIT_WAIT_TIMEOUT_MS = 5000
|
||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||
|
||||
interface ManagerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
id: string
|
||||
child: ChildProcess
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class BackgroundProcessManager {
|
||||
private readonly running = new Map<string, RunningProcess>()
|
||||
|
||||
constructor(private readonly deps: ManagerDeps) {
|
||||
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
|
||||
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
|
||||
}
|
||||
|
||||
async list(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
return enriched
|
||||
}
|
||||
|
||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const id = this.generateId()
|
||||
const processDir = await this.ensureProcessDir(workspaceId, id)
|
||||
const outputPath = path.join(processDir, OUTPUT_FILE)
|
||||
|
||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||
|
||||
const child = spawn("bash", ["-c", command], {
|
||||
cwd: workspace.path,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
|
||||
child.on("exit", () => {
|
||||
this.killProcessTree(child, "SIGTERM")
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
command,
|
||||
cwd: workspace.path,
|
||||
status: "running",
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
outputSizeBytes: 0,
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.on("close", async (code) => {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
this.running.delete(id)
|
||||
|
||||
record.status = this.statusFromExit(code)
|
||||
record.exitCode = code === null ? undefined : code
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastPublishAt = now
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
child.stderr?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) return
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
})
|
||||
}
|
||||
|
||||
async readOutput(
|
||||
workspaceId: string,
|
||||
processId: string,
|
||||
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
|
||||
) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
|
||||
}
|
||||
|
||||
const stats = await fs.stat(outputPath)
|
||||
const sizeBytes = stats.size
|
||||
const method = options.method ?? "full"
|
||||
const lineCount = options.lines ?? 10
|
||||
|
||||
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
|
||||
let content = raw
|
||||
|
||||
switch (method) {
|
||||
case "head":
|
||||
content = this.headLines(raw, lineCount)
|
||||
break
|
||||
case "tail":
|
||||
content = this.tailLines(raw, lineCount)
|
||||
break
|
||||
case "grep":
|
||||
if (!options.pattern) {
|
||||
throw new Error("Pattern is required for grep output")
|
||||
}
|
||||
content = this.grepLines(raw, options.pattern)
|
||||
break
|
||||
default:
|
||||
content = raw
|
||||
}
|
||||
|
||||
const effectiveMaxBytes = options.maxBytes
|
||||
return {
|
||||
id: processId,
|
||||
content,
|
||||
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
|
||||
sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async streamOutput(workspaceId: string, processId: string, reply: any) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
reply.code(404).send({ error: "Output not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const file = await fs.open(outputPath, "r")
|
||||
let position = (await file.stat()).size
|
||||
|
||||
const tick = async () => {
|
||||
const stats = await file.stat()
|
||||
if (stats.size <= position) return
|
||||
|
||||
const length = stats.size - position
|
||||
const buffer = Buffer.alloc(length)
|
||||
await file.read(buffer, 0, length, position)
|
||||
position = stats.size
|
||||
|
||||
const content = buffer.toString("utf-8")
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
tick().catch((error) => {
|
||||
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(interval)
|
||||
file.close().catch(() => undefined)
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
reply.raw.on("close", close)
|
||||
reply.raw.on("error", close)
|
||||
}
|
||||
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
this.killProcessTree(running.child, "SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
await this.removeWorkspaceDir(workspaceId)
|
||||
}
|
||||
|
||||
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
const pid = child.pid
|
||||
if (!pid) return
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
} catch {
|
||||
// Fall back to killing the direct child.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForExit(running: RunningProcess) {
|
||||
let exited = false
|
||||
const exitPromise = running.exitPromise.finally(() => {
|
||||
exited = true
|
||||
})
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
if (!exited) {
|
||||
this.killProcessTree(running.child, "SIGKILL")
|
||||
}
|
||||
}, STOP_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
exitPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
|
||||
if (!exited) {
|
||||
this.killProcessTree(running.child, "SIGKILL")
|
||||
this.running.delete(running.id)
|
||||
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(killTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
}
|
||||
|
||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||
if (maxBytes === undefined || sizeBytes <= maxBytes) {
|
||||
return await fs.readFile(outputPath, "utf-8")
|
||||
}
|
||||
|
||||
const start = Math.max(0, sizeBytes - maxBytes)
|
||||
const file = await fs.open(outputPath, "r")
|
||||
const buffer = Buffer.alloc(sizeBytes - start)
|
||||
await file.read(buffer, 0, buffer.length, start)
|
||||
await file.close()
|
||||
return buffer.toString("utf-8")
|
||||
}
|
||||
|
||||
private headLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(0, Math.max(0, lines)).join("\n")
|
||||
}
|
||||
|
||||
private tailLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
|
||||
}
|
||||
|
||||
private grepLines(input: string, pattern: string): string {
|
||||
let matcher: RegExp
|
||||
try {
|
||||
matcher = new RegExp(pattern)
|
||||
} catch {
|
||||
throw new Error("Invalid grep pattern")
|
||||
}
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => matcher.test(line))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
private async ensureProcessDir(workspaceId: string, processId: string) {
|
||||
const root = await this.ensureWorkspaceDir(workspaceId)
|
||||
const processDir = path.join(root, processId)
|
||||
await fs.mkdir(processDir, { recursive: true })
|
||||
return processDir
|
||||
}
|
||||
|
||||
private async ensureWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
private getOutputPath(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||
}
|
||||
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
return records.find((entry) => entry.id === processId) ?? null
|
||||
}
|
||||
|
||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
records[index] = record
|
||||
} else {
|
||||
records.push(record)
|
||||
}
|
||||
await this.writeIndex(workspaceId, records)
|
||||
}
|
||||
|
||||
private async removeFromIndex(workspaceId: string, processId: string) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const next = records.filter((entry) => entry.id !== processId)
|
||||
await this.writeIndex(workspaceId, next)
|
||||
}
|
||||
|
||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||
}
|
||||
|
||||
private async getIndexPath(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
|
||||
}
|
||||
|
||||
private async removeProcessDir(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
|
||||
await fs.rm(processDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async removeWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(outputPath)
|
||||
return stats.size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.updated", properties: { process: record } },
|
||||
})
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
return `proc_${timestamp}_${random}`
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,17 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
|
||||
|
||||
const PreferencesSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().default(false),
|
||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showTimelineTools: z.boolean().default(true),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
|
||||
@@ -52,9 +52,10 @@ export class ConfigStore {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
const published = Boolean(this.eventBus)
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.info("Config updated")
|
||||
this.logger.debug({ config: this.cache }, "Config payload")
|
||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||
this.logger.trace({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
|
||||
@@ -8,7 +8,12 @@ export class EventBus extends EventEmitter {
|
||||
}
|
||||
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
this.logger?.debug({ event }, "Publishing workspace event")
|
||||
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||
if (this.logger?.isLevelEnabled("trace")) {
|
||||
this.logger.trace({ event }, "Workspace event payload")
|
||||
}
|
||||
}
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
|
||||
@@ -22,6 +27,9 @@ export class EventBus extends EventEmitter {
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
this.on("app.releaseAvailable", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
@@ -31,6 +39,9 @@ export class EventBus extends EventEmitter {
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
this.off("app.releaseAvailable", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,13 @@ import { FileSystemBrowser } from "./filesystem/browser"
|
||||
import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -78,9 +81,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
const normalizedHost = resolveHost(parsed.host)
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: parsed.host,
|
||||
host: normalizedHost,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
@@ -100,6 +105,13 @@ function parsePort(input: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
if (input && input.trim() === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliOptions(process.argv.slice(2))
|
||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||
@@ -110,6 +122,18 @@ async function main() {
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||
port: options.port,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
@@ -118,16 +142,28 @@ async function main() {
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
}
|
||||
const releaseMonitor = startReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
logger: logger.child({ component: "release-monitor" }),
|
||||
onUpdate: (release) => {
|
||||
if (release) {
|
||||
serverMeta.latestRelease = release
|
||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
||||
} else {
|
||||
delete serverMeta.latestRelease
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
@@ -169,12 +205,15 @@ async function main() {
|
||||
}
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
|
||||
releaseMonitor.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
31
packages/server/src/opencode-config.ts
Normal file
31
packages/server/src/opencode-config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const prodTemplateDirs = [
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
||||
path.resolve(__dirname, "opencode-config"),
|
||||
].filter((dir): dir is string => Boolean(dir))
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild
|
||||
? devTemplateDir
|
||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
|
||||
}
|
||||
|
||||
if (isDevBuild) {
|
||||
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
|
||||
}
|
||||
|
||||
return templateDir
|
||||
}
|
||||
55
packages/server/src/plugins/channel.ts
Normal file
55
packages/server/src/plugins/channel.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FastifyReply } from "fastify"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
export interface PluginOutboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ClientConnection {
|
||||
reply: FastifyReply
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class PluginChannelManager {
|
||||
private readonly clients = new Set<ClientConnection>()
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
register(workspaceId: string, reply: FastifyReply) {
|
||||
const connection: ClientConnection = { workspaceId, reply }
|
||||
this.clients.add(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
this.clients.delete(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
|
||||
}
|
||||
|
||||
return { close }
|
||||
}
|
||||
|
||||
send(workspaceId: string, event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
if (client.workspaceId !== workspaceId) continue
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
private write(reply: FastifyReply, event: PluginOutboundEvent) {
|
||||
try {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
|
||||
}
|
||||
}
|
||||
}
|
||||
36
packages/server/src/plugins/handlers.ts
Normal file
36
packages/server/src/plugins/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { PluginOutboundEvent } from "./channel"
|
||||
|
||||
export interface PluginInboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface HandlerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
|
||||
switch (event.type) {
|
||||
case "codenomad.pong":
|
||||
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
|
||||
return
|
||||
|
||||
default:
|
||||
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPingEvent(): PluginOutboundEvent {
|
||||
|
||||
return {
|
||||
type: "codenomad.ping",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
141
packages/server/src/releases/release-monitor.ts
Normal file
141
packages/server/src/releases/release-monitor.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fetch } from "undici"
|
||||
import type { LatestReleaseInfo } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
interface ReleaseMonitorOptions {
|
||||
currentVersion: string
|
||||
logger: Logger
|
||||
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||
}
|
||||
|
||||
interface GithubReleaseResponse {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
html_url?: string
|
||||
body?: string
|
||||
published_at?: string
|
||||
created_at?: string
|
||||
prerelease?: boolean
|
||||
}
|
||||
|
||||
interface NormalizedVersion {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
prerelease: string | null
|
||||
}
|
||||
|
||||
export interface ReleaseMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
|
||||
let stopped = false
|
||||
|
||||
const refreshRelease = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const release = await fetchLatestRelease(options)
|
||||
options.onUpdate(release)
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error }, "Failed to refresh release information")
|
||||
}
|
||||
}
|
||||
|
||||
void refreshRelease()
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||
const response = await fetch(RELEASES_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release API responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as GithubReleaseResponse
|
||||
const tagFromServer = json.tag_name || json.name
|
||||
if (!tagFromServer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedVersion = stripTagPrefix(tagFromServer)
|
||||
if (!normalizedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = parseVersion(options.currentVersion)
|
||||
const remote = parseVersion(normalizedVersion)
|
||||
|
||||
if (compareVersions(remote, current) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
tag: tagFromServer,
|
||||
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
|
||||
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
|
||||
publishedAt: json.published_at ?? json.created_at,
|
||||
notes: json.body,
|
||||
}
|
||||
}
|
||||
|
||||
function stripTagPrefix(tag: string | undefined): string | null {
|
||||
if (!tag) return null
|
||||
const trimmed = tag.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.replace(/^v/i, "")
|
||||
}
|
||||
|
||||
function parseVersion(value: string): NormalizedVersion {
|
||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||
const [core, prerelease = null] = normalized.split("-", 2)
|
||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
})
|
||||
return {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
|
||||
if (a.major !== b.major) {
|
||||
return a.major > b.major ? 1 : -1
|
||||
}
|
||||
if (a.minor !== b.minor) {
|
||||
return a.minor > b.minor ? 1 : -1
|
||||
}
|
||||
if (a.patch !== b.patch) {
|
||||
return a.patch > b.patch ? 1 : -1
|
||||
}
|
||||
|
||||
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
|
||||
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
|
||||
|
||||
if (aPre === bPre) {
|
||||
return 0
|
||||
}
|
||||
if (!aPre) {
|
||||
return 1
|
||||
}
|
||||
if (!bPre) {
|
||||
return -1
|
||||
}
|
||||
return aPre.localeCompare(bPre)
|
||||
}
|
||||
@@ -18,8 +18,11 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
@@ -42,9 +45,13 @@ interface HttpServerStartResult {
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
|
||||
const sseClients = new Set<() => void>()
|
||||
const registerSseClient = (cleanup: () => void) => {
|
||||
@@ -58,6 +65,29 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
sseClients.clear()
|
||||
}
|
||||
|
||||
app.addHook("onRequest", (request, _reply, done) => {
|
||||
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
|
||||
start: process.hrtime.bigint(),
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.addHook("onResponse", (request, reply, done) => {
|
||||
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
|
||||
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
|
||||
const base = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: reply.statusCode,
|
||||
durationMs,
|
||||
}
|
||||
apiLogger.debug(base, "HTTP request completed")
|
||||
if (apiLogger.isLevelEnabled("trace")) {
|
||||
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
@@ -65,18 +95,32 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
app.register(replyFrom, {
|
||||
contentTypesToEncode: [],
|
||||
undici: {
|
||||
connections: 16,
|
||||
pipelining: 1,
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const backgroundProcessManager = new BackgroundProcessManager({
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
@@ -89,16 +133,40 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
let actualPort = deps.port
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
|
||||
if (typeof addressInfo === "string") {
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
const err = error as NodeJS.ErrnoException | undefined
|
||||
return Boolean(err && err.code === "EADDRINUSE")
|
||||
}
|
||||
|
||||
let listenResult
|
||||
|
||||
try {
|
||||
listenResult = await attemptListen(primaryPort)
|
||||
} catch (error) {
|
||||
if (!shouldRetryWithEphemeral(error)) {
|
||||
throw error
|
||||
}
|
||||
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
||||
listenResult = await attemptListen(0)
|
||||
}
|
||||
|
||||
let actualPort = listenResult.requestedPort
|
||||
|
||||
if (typeof listenResult.addressInfo === "string") {
|
||||
try {
|
||||
const parsed = new URL(addressInfo)
|
||||
actualPort = Number(parsed.port) || deps.port
|
||||
const parsed = new URL(listenResult.addressInfo)
|
||||
actualPort = Number(parsed.port) || listenResult.requestedPort
|
||||
} catch {
|
||||
actualPort = deps.port
|
||||
actualPort = listenResult.requestedPort
|
||||
}
|
||||
} else {
|
||||
const address = app.server.address()
|
||||
@@ -111,6 +179,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
@@ -190,6 +261,11 @@ async function proxyWorkspaceRequest(args: {
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
|
||||
85
packages/server/src/server/routes/background-processes.ts
Normal file
85
packages/server/src/server/routes/background-processes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { BackgroundProcessManager } from "../../background-processes/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
backgroundProcessManager: BackgroundProcessManager
|
||||
}
|
||||
|
||||
const StartSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
command: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const OutputQuerySchema = z.object({
|
||||
method: z.enum(["full", "tail", "head", "grep"]).optional(),
|
||||
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
|
||||
pattern: z.string().optional(),
|
||||
lines: z.coerce.number().int().positive().max(2000).optional(),
|
||||
maxBytes: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
|
||||
const processes = await deps.backgroundProcessManager.list(request.params.id)
|
||||
return { processes }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||
const payload = StartSchema.parse(request.body ?? {})
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
||||
reply.code(201)
|
||||
return process
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/stop",
|
||||
async (request, reply) => {
|
||||
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
|
||||
if (!process) {
|
||||
reply.code(404)
|
||||
return { error: "Process not found" }
|
||||
}
|
||||
return process
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/terminate",
|
||||
async (request, reply) => {
|
||||
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
|
||||
reply.code(204)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/output",
|
||||
async (request, reply) => {
|
||||
const query = OutputQuerySchema.parse(request.query ?? {})
|
||||
const method = query.method ?? query.mode
|
||||
if (method === "grep" && !query.pattern) {
|
||||
reply.code(400)
|
||||
return { error: "Pattern is required for grep output" }
|
||||
}
|
||||
try {
|
||||
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
|
||||
method,
|
||||
pattern: query.pattern,
|
||||
lines: query.lines,
|
||||
maxBytes: query.maxBytes,
|
||||
})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid output request" }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/stream",
|
||||
async (request, reply) => {
|
||||
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.hijack()
|
||||
|
||||
const send = (event: WorkspaceEventPayload) => {
|
||||
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
|
||||
if (deps.logger.isLevelEnabled("trace")) {
|
||||
deps.logger.trace({ clientId, event }, "SSE event payload")
|
||||
}
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
reply.raw.end?.()
|
||||
deps.logger.debug({ clientId }, "SSE client disconnected")
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
|
||||
@@ -1,10 +1,104 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import os from "os"
|
||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/meta", async () => deps.serverMeta)
|
||||
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
75
packages/server/src/server/routes/plugin.ts
Normal file
75
packages/server/src/server/routes/plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
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"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const registration = channel.register(request.params.id, reply)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat)
|
||||
registration.close()
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
request.raw.on("close", close)
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = (request.params["*"] as string | undefined) ?? ""
|
||||
const normalized = suffix.replace(/^\/+/, "")
|
||||
|
||||
if (normalized === "event" && request.method === "POST") {
|
||||
const parsed = PluginEventSchema.parse(request.body ?? {})
|
||||
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
|
||||
reply.code(204).send()
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(404).send({ error: "Unknown plugin endpoint" })
|
||||
}
|
||||
|
||||
app.all("/workspaces/:id/plugin/*", handleWildcard)
|
||||
app.all("/workspaces/:id/plugin", handleWildcard)
|
||||
}
|
||||
@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
reply.code(400).type("text/plain").send(message)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
|
||||
195
packages/server/src/workspaces/instance-events.ts
Normal file
195
packages/server/src/workspaces/instance-events.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Agent, fetch } from "undici"
|
||||
import { Agent as UndiciAgent } from "undici"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "./manager"
|
||||
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||
|
||||
const INSTANCE_HOST = "127.0.0.1"
|
||||
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
|
||||
interface InstanceEventBridgeOptions {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface ActiveStream {
|
||||
controller: AbortController
|
||||
task: Promise<void>
|
||||
}
|
||||
|
||||
export class InstanceEventBridge {
|
||||
private readonly streams = new Map<string, ActiveStream>()
|
||||
|
||||
constructor(private readonly options: InstanceEventBridgeOptions) {
|
||||
const bus = this.options.eventBus
|
||||
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
|
||||
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
|
||||
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [id, active] of this.streams) {
|
||||
active.controller.abort()
|
||||
this.publishStatus(id, "disconnected")
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
private startStream(workspaceId: string) {
|
||||
if (this.streams.has(workspaceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const task = this.runStream(workspaceId, controller.signal)
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (active?.controller === controller) {
|
||||
this.streams.delete(workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
this.streams.set(workspaceId, { controller, task })
|
||||
}
|
||||
|
||||
private stopStream(workspaceId: string, reason?: string) {
|
||||
const active = this.streams.get(workspaceId)
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active.controller.abort()
|
||||
this.streams.delete(workspaceId)
|
||||
this.publishStatus(workspaceId, "disconnected", reason)
|
||||
}
|
||||
|
||||
private async runStream(workspaceId: string, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
const port = this.options.workspaceManager.getInstancePort(workspaceId)
|
||||
if (!port) {
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
continue
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connecting")
|
||||
|
||||
try {
|
||||
await this.consumeStream(workspaceId, port, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
|
||||
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal,
|
||||
dispatcher: STREAM_AGENT,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Instance event stream unavailable (${response.status})`)
|
||||
}
|
||||
|
||||
this.publishStatus(workspaceId, "connected")
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
buffer = this.flushEvents(buffer, workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
private flushEvents(buffer: string, workspaceId: string) {
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
this.processChunk(chunk, workspaceId)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private processChunk(chunk: string, workspaceId: string) {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) {
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
|
||||
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||
}
|
||||
|
||||
private delay(duration: number, signal: AbortSignal) {
|
||||
if (duration <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, duration)
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -6,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
@@ -15,6 +20,7 @@ interface WorkspaceManagerOptions {
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
@@ -22,9 +28,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly opencodeConfigDir: string
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
this.opencodeConfigDir = getOpencodeConfigDir()
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
@@ -65,10 +73,11 @@ export class WorkspaceManager {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
@@ -79,27 +88,42 @@ export class WorkspaceManager {
|
||||
name,
|
||||
status: "starting",
|
||||
proxyPath,
|
||||
binaryId: binary.id,
|
||||
binaryId: resolvedBinaryPath,
|
||||
binaryLabel: binary.label,
|
||||
binaryVersion: binary.version,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
}
|
||||
|
||||
try {
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: binary.path,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
environment,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
@@ -161,6 +185,225 @@ export class WorkspaceManager {
|
||||
return workspace
|
||||
}
|
||||
|
||||
private resolveBinaryPath(identifier: string): string {
|
||||
if (!identifier) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
||||
if (path.isAbsolute(identifier) || looksLikePath) {
|
||||
return identifier
|
||||
}
|
||||
|
||||
const locator = process.platform === "win32" ? "where" : "which"
|
||||
|
||||
try {
|
||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const resolved = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
|
||||
if (resolved) {
|
||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||
return resolved
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||
if (line) {
|
||||
const normalized = line.trim()
|
||||
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
if (versionMatch) {
|
||||
const version = versionMatch[1]
|
||||
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||
return version
|
||||
}
|
||||
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||
return normalized
|
||||
}
|
||||
} else if (result.error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited before becoming ready",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited shortly after start",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited during health checks",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
if (latestOutput) {
|
||||
throw new Error(latestOutput)
|
||||
}
|
||||
const reason = probeResult.reason ?? "Health check failed"
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
|
||||
private buildStartupError(
|
||||
workspaceId: string,
|
||||
phase: string,
|
||||
exitInfo: ProcessExitInfo,
|
||||
lastOutput: string,
|
||||
): Error {
|
||||
const exitDetails = this.describeExit(exitInfo)
|
||||
const trimmedOutput = lastOutput.trim()
|
||||
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||
}
|
||||
|
||||
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let settled = false
|
||||
let retryTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const tryConnect = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolve()
|
||||
})
|
||||
socket.once("error", () => {
|
||||
socket.destroy()
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
cleanup()
|
||||
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||
} else {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
tryConnect()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
|
||||
private delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||
}
|
||||
|
||||
private describeExit(info: ProcessExitInfo): string {
|
||||
if (info.signal) {
|
||||
return `signal ${info.signal}`
|
||||
}
|
||||
if (info.code !== null) {
|
||||
return `code ${info.code}`
|
||||
}
|
||||
return "unknown reason"
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
@@ -13,7 +13,7 @@ interface LaunchOptions {
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
interface ProcessExitInfo {
|
||||
export interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
@@ -30,14 +30,47 @@ export class WorkspaceRuntime {
|
||||
|
||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||
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 env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
||||
exitResolve = resolveExit
|
||||
})
|
||||
|
||||
// Store recent output for debugging - keep last 50 lines from each stream
|
||||
const MAX_OUTPUT_LINES = 50
|
||||
const recentStdout: string[] = []
|
||||
const recentStderr: string[] = []
|
||||
const getLastOutput = () => {
|
||||
const combined: string[] = []
|
||||
if (recentStderr.length > 0) {
|
||||
combined.push("Error Stream")
|
||||
combined.push(...recentStderr.slice(-10))
|
||||
}
|
||||
if (recentStdout.length > 0) {
|
||||
combined.push("Output Stream")
|
||||
combined.push(...recentStdout.slice(-10))
|
||||
}
|
||||
return combined.join("\n")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
|
||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
args,
|
||||
commandLine,
|
||||
env,
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
@@ -80,11 +113,22 @@ export class WorkspaceRuntime {
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
child.removeListener("exit", handleExit)
|
||||
const exitInfo: ProcessExitInfo = {
|
||||
workspaceId: options.workspaceId,
|
||||
code,
|
||||
signal,
|
||||
requested: managed.requestedStop,
|
||||
}
|
||||
if (exitResolve) {
|
||||
exitResolve(exitInfo)
|
||||
exitResolve = null
|
||||
}
|
||||
if (!portFound) {
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
const recentOutput = getLastOutput().trim()
|
||||
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
options.onExit?.(exitInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +137,10 @@ export class WorkspaceRuntime {
|
||||
child.removeListener("exit", handleExit)
|
||||
this.processes.delete(options.workspaceId)
|
||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||
if (exitResolve) {
|
||||
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
||||
exitResolve = null
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
@@ -106,18 +154,25 @@ export class WorkspaceRuntime {
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
cleanupStreams()
|
||||
stopWarningTimer()
|
||||
child.removeListener("error", handleError)
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
||||
resolve({ pid: child.pid!, port })
|
||||
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +185,14 @@ export class WorkspaceRuntime {
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
recentStderr.push(trimmed)
|
||||
if (recentStderr.length > MAX_OUTPUT_LINES) {
|
||||
recentStderr.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
|
||||
7
packages/tauri-app/.gitignore
vendored
Normal file
7
packages/tauri-app/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
src-tauri/target
|
||||
src-tauri/Cargo.lock
|
||||
src-tauri/resources/
|
||||
target
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
5589
packages/tauri-app/Cargo.lock
generated
Normal file
5589
packages/tauri-app/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/tauri-app/Cargo.toml
Normal file
3
packages/tauri-app/Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["src-tauri"]
|
||||
resolver = "2"
|
||||
17
packages/tauri-app/package.json
Normal file
17
packages/tauri-app/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"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",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"bundle:server": "npm run prebuild",
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
46
packages/tauri-app/scripts/dev-prep.js
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[dev-prep] UI loader build missing; running workspace build…")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[dev-prep] failed to produce loading.html after UI build")
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureUiBuild()
|
||||
copyUiLoadingAssets()
|
||||
195
packages/tauri-app/scripts/prebuild.js
Normal file
195
packages/tauri-app/scripts/prebuild.js
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const root = path.resolve(__dirname, "..")
|
||||
const workspaceRoot = path.resolve(root, "..", "..")
|
||||
const serverRoot = path.resolve(root, "..", "server")
|
||||
const uiRoot = path.resolve(root, "..", "ui")
|
||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
|
||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||
|
||||
const sources = ["dist", "public", "node_modules", "package.json"]
|
||||
|
||||
const serverInstallCommand =
|
||||
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||
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 envWithRootBin = {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
}
|
||||
|
||||
const braceExpansionPath = path.join(
|
||||
serverRoot,
|
||||
"node_modules",
|
||||
"@fastify",
|
||||
"static",
|
||||
"node_modules",
|
||||
"brace-expansion",
|
||||
"package.json",
|
||||
)
|
||||
|
||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||
|
||||
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...")
|
||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||
throw new Error("[prebuild] server artifacts still missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureUiBuild() {
|
||||
const loadingHtml = path.join(uiDist, "loading.html")
|
||||
if (fs.existsSync(loadingHtml)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ui build missing; running workspace build...")
|
||||
execSync("npm --workspace @codenomad/ui run build", {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
if (!fs.existsSync(loadingHtml)) {
|
||||
throw new Error("[prebuild] ui loading assets missing after build")
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDevDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
||||
execSync(serverDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(braceExpansionPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring server production dependencies...")
|
||||
execSync(serverInstallCommand, {
|
||||
cwd: serverRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function ensureUiDevDependencies() {
|
||||
if (fs.existsSync(viteBinPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[prebuild] ensuring ui build dependencies...")
|
||||
execSync(uiDevInstallCommand, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: envWithRootBin,
|
||||
})
|
||||
}
|
||||
|
||||
function ensureRollupPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@rollup/rollup-linux-x64-gnu",
|
||||
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let rollupVersion = ""
|
||||
try {
|
||||
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
|
||||
} catch (error) {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
|
||||
|
||||
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
|
||||
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
|
||||
for (const name of sources) {
|
||||
const from = path.join(serverRoot, name)
|
||||
const to = path.join(serverDest, name)
|
||||
if (!fs.existsSync(from)) {
|
||||
console.warn(`[prebuild] skipped missing ${from}`)
|
||||
continue
|
||||
}
|
||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||
console.log(`[prebuild] copied ${from} -> ${to}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUiLoadingAssets() {
|
||||
const loadingSource = path.join(uiDist, "loading.html")
|
||||
const assetsSource = path.join(uiDist, "assets")
|
||||
|
||||
if (!fs.existsSync(loadingSource)) {
|
||||
throw new Error("[prebuild] cannot find built loading.html")
|
||||
}
|
||||
|
||||
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||
|
||||
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||
if (fs.existsSync(assetsSource)) {
|
||||
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||
}
|
||||
|
||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||
}
|
||||
|
||||
ensureServerDevDependencies()
|
||||
ensureUiDevDependencies()
|
||||
ensureRollupPlatformBinary()
|
||||
ensureServerDependencies()
|
||||
ensureServerBuild()
|
||||
ensureUiBuild()
|
||||
copyServerArtifacts()
|
||||
copyUiLoadingAssets()
|
||||
23
packages/tauri-app/src-tauri/Cargo.toml
Normal file
23
packages/tauri-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
3
packages/tauri-app/src-tauri/build.rs
Normal file
3
packages/tauri-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||
"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:*"]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +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:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
2543
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2543
packages/tauri-app/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
2543
packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
BIN
packages/tauri-app/src-tauri/icon.icns
Normal file
Binary file not shown.
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
BIN
packages/tauri-app/src-tauri/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
BIN
packages/tauri-app/src-tauri/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
712
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
712
packages/tauri-app/src-tauri/src/cli_manager.rs
Normal file
@@ -0,0 +1,712 @@
|
||||
use dirs::home_dir;
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
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 tauri::{AppHandle, Emitter, Manager, Url};
|
||||
|
||||
fn log_line(message: &str) {
|
||||
println!("[tauri-cli] {message}");
|
||||
}
|
||||
|
||||
fn workspace_root() -> Option<PathBuf> {
|
||||
std::env::current_dir().ok().and_then(|mut dir| {
|
||||
for _ in 0..3 {
|
||||
if let Some(parent) = dir.parent() {
|
||||
dir = parent.to_path_buf();
|
||||
}
|
||||
}
|
||||
Some(dir)
|
||||
})
|
||||
}
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
log_line(&format!("navigating main to {url}"));
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
let _ = win.navigate(parsed);
|
||||
} else {
|
||||
log_line("failed to parse URL for navigation");
|
||||
}
|
||||
} else {
|
||||
log_line("main window not found for navigation");
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PreferencesConfig {
|
||||
#[serde(rename = "listeningMode")]
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> PathBuf {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
expand_home(&raw)
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path.starts_with("~/") {
|
||||
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
||||
return home.join(path.trim_start_matches("~/"));
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn resolve_listening_mode() -> String {
|
||||
let path = resolve_config_path();
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
if mode == "all" {
|
||||
return "all".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"local".to_string()
|
||||
}
|
||||
|
||||
fn resolve_listening_host() -> String {
|
||||
let mode = resolve_listening_mode();
|
||||
if mode == "local" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CliState {
|
||||
Starting,
|
||||
Ready,
|
||||
Error,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CliStatus {
|
||||
pub state: CliState,
|
||||
pub pid: Option<u32>,
|
||||
pub port: Option<u16>,
|
||||
pub url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CliStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: CliState::Stopped,
|
||||
pid: None,
|
||||
port: None,
|
||||
url: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliProcessManager {
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CliProcessManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
ready: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
|
||||
log_line(&format!("start requested (dev={dev})"));
|
||||
self.stop()?;
|
||||
self.ready.store(false, Ordering::SeqCst);
|
||||
{
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Starting;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
status.pid = None;
|
||||
}
|
||||
Self::emit_status(&app, &self.status.lock());
|
||||
|
||||
let status_arc = self.status.clone();
|
||||
let child_arc = self.child.clone();
|
||||
let ready_flag = self.ready.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
||||
log_line(&format!("cli spawn failed: {err}"));
|
||||
let mut locked = status_arc.lock();
|
||||
locked.state = CliState::Error;
|
||||
locked.error = Some(err.to_string());
|
||||
let snapshot = locked.clone();
|
||||
drop(locked);
|
||||
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
|
||||
let _ = app.emit("cli:status", snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut status = self.status.lock();
|
||||
status.state = CliState::Stopped;
|
||||
status.pid = None;
|
||||
status.port = None;
|
||||
status.url = None;
|
||||
status.error = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> CliStatus {
|
||||
self.status.lock().clone()
|
||||
}
|
||||
|
||||
fn spawn_cli(
|
||||
app: AppHandle,
|
||||
status: Arc<Mutex<CliStatus>>,
|
||||
child_holder: Arc<Mutex<Option<Child>>>,
|
||||
ready: Arc<AtomicBool>,
|
||||
dev: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
let resolution = CliEntry::resolve(&app, dev)?;
|
||||
let host = resolve_listening_host();
|
||||
log_line(&format!(
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
}
|
||||
|
||||
let cwd = workspace_root();
|
||||
if let Some(ref c) = cwd {
|
||||
log_line(&format!("using cwd={}", c.display()));
|
||||
}
|
||||
|
||||
let command_info = if supports_user_shell() {
|
||||
log_line("spawning via user shell");
|
||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||
} else {
|
||||
log_line("spawning directly with node");
|
||||
ShellCommandType::Direct(DirectCommand {
|
||||
program: resolution.node_binary.clone(),
|
||||
args: resolution.runner_args(&args),
|
||||
})
|
||||
};
|
||||
|
||||
if !supports_user_shell() {
|
||||
if which::which(&resolution.node_binary).is_err() {
|
||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
||||
}
|
||||
}
|
||||
|
||||
let child = match &command_info {
|
||||
ShellCommandType::UserShell(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||
let mut c = Command::new(&cmd.shell);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
ShellCommandType::Direct(cmd) => {
|
||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||
let mut c = Command::new(&cmd.program);
|
||||
c.args(&cmd.args)
|
||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(ref cwd) = cwd {
|
||||
c.current_dir(cwd);
|
||||
}
|
||||
c.spawn()?
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
log_line(&format!("spawned pid={pid}"));
|
||||
{
|
||||
let mut locked = status.lock();
|
||||
locked.pid = Some(pid);
|
||||
}
|
||||
Self::emit_status(&app, &status.lock());
|
||||
|
||||
{
|
||||
let mut holder = child_holder.lock();
|
||||
*holder = Some(child);
|
||||
}
|
||||
|
||||
let child_clone = child_holder.clone();
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stdout.take())
|
||||
.map(BufReader::new);
|
||||
let stderr = child_clone
|
||||
.lock()
|
||||
.as_mut()
|
||||
.and_then(|c| c.stderr.take())
|
||||
.map(BufReader::new);
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
||||
}
|
||||
});
|
||||
|
||||
let app_clone = app.clone();
|
||||
let status_clone = status.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
if ready_clone.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let mut locked = status_clone.lock();
|
||||
locked.state = CliState::Error;
|
||||
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();
|
||||
}
|
||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
let status_clone = status.clone();
|
||||
let app_clone = app.clone();
|
||||
thread::spawn(move || {
|
||||
let code = {
|
||||
let mut guard = child_holder.lock();
|
||||
if let Some(child) = guard.as_mut() {
|
||||
child.wait().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut locked = status_clone.lock();
|
||||
let failed = locked.state != CliState::Ready;
|
||||
let err_msg = if failed {
|
||||
Some(match code {
|
||||
Some(status) => format!("CLI exited early: {status}"),
|
||||
None => "CLI exited early".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if failed {
|
||||
locked.state = CliState::Error;
|
||||
if locked.error.is_none() {
|
||||
locked.error = err_msg.clone();
|
||||
}
|
||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
||||
} else {
|
||||
locked.state = CliState::Stopped;
|
||||
log_line("cli process stopped cleanly");
|
||||
}
|
||||
|
||||
Self::emit_status(&app_clone, &locked);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_stream<R: BufRead>(
|
||||
mut reader: R,
|
||||
stream: &str,
|
||||
app: &AppHandle,
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
||||
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_line(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let line = buffer.trim_end();
|
||||
if !line.is_empty() {
|
||||
log_line(&format!("[cli][{}] {}", stream, line));
|
||||
|
||||
if ready.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(port) = port_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, port);
|
||||
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, 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, port as u16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
let mut locked = status.lock();
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
locked.port = Some(port);
|
||||
locked.url = Some(url.clone());
|
||||
locked.state = CliState::Ready;
|
||||
locked.error = None;
|
||||
log_line(&format!("cli ready on {url}"));
|
||||
navigate_main(app, &url);
|
||||
let _ = app.emit("cli:ready", locked.clone());
|
||||
Self::emit_status(app, &locked);
|
||||
}
|
||||
|
||||
fn emit_status(app: &AppHandle, status: &CliStatus) {
|
||||
let _ = app.emit("cli:status", status.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_user_shell() -> bool {
|
||||
cfg!(unix)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShellCommand {
|
||||
shell: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DirectCommand {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ShellCommandType {
|
||||
UserShell(ShellCommand),
|
||||
Direct(DirectCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliEntry {
|
||||
entry: String,
|
||||
runner: Runner,
|
||||
runner_path: Option<String>,
|
||||
node_binary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Runner {
|
||||
Node,
|
||||
Tsx,
|
||||
}
|
||||
|
||||
impl CliEntry {
|
||||
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
|
||||
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
||||
|
||||
if dev {
|
||||
if let Some(tsx_path) = resolve_tsx(app) {
|
||||
if let Some(entry) = resolve_dev_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Tsx,
|
||||
runner_path: Some(tsx_path),
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = resolve_dist_entry(app) {
|
||||
return Ok(Self {
|
||||
entry,
|
||||
runner: Runner::Node,
|
||||
runner_path: None,
|
||||
node_binary,
|
||||
});
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
if dev {
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||
let mut args = VecDeque::new();
|
||||
if self.runner == Runner::Tsx {
|
||||
if let Some(path) = &self.runner_path {
|
||||
args.push_back(path.clone());
|
||||
}
|
||||
}
|
||||
args.push_back(self.entry.clone());
|
||||
for arg in cli_args {
|
||||
args.push_back(arg.clone());
|
||||
}
|
||||
args.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.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"))),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
||||
let candidates = vec![
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("packages/server/src/index.ts")),
|
||||
std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("../server/src/index.ts")),
|
||||
];
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
let base = workspace_root();
|
||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||
];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||
|
||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||
for root in linux_resource_roots {
|
||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/index.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first_existing(candidates)
|
||||
}
|
||||
|
||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
||||
|
||||
let shell = default_shell();
|
||||
let mut quoted: Vec<String> = Vec::new();
|
||||
quoted.push(shell_escape(&entry.node_binary));
|
||||
for arg in entry.runner_args(cli_args) {
|
||||
quoted.push(shell_escape(&arg));
|
||||
}
|
||||
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
||||
let args = build_shell_args(&shell, &command);
|
||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||
Ok(ShellCommand { shell, args })
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
if !shell.trim().is_empty() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
"/bin/zsh".to_string()
|
||||
} else {
|
||||
"/bin/bash".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
if input.is_empty() {
|
||||
"''".to_string()
|
||||
} else if !input
|
||||
.chars()
|
||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
||||
{
|
||||
input.to_string()
|
||||
} else {
|
||||
let escaped = input.replace('\'', "'\\''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.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()]
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
paths
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|p| p.exists())
|
||||
.map(|p| normalize_path(p))
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
267
packages/tauri-app/src-tauri/src/main.rs
Normal file
267
packages/tauri-app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod cli_manager;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use serde_json::json;
|
||||
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_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub manager: CliProcessManager,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||
state.manager.status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||
let dev_mode = is_dev_mode();
|
||||
state.manager.stop().map_err(|e| e.to_string())?;
|
||||
state
|
||||
.manager
|
||||
.start(app, dev_mode)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.as_str(), None::<&str>)
|
||||
{
|
||||
eprintln!("[tauri] failed to open external link {}: {}", url, err);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
.build();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
match event.id().0.as_str() {
|
||||
// File menu
|
||||
"new_instance" => {
|
||||
if let Some(window) = app_handle.get_webview_window("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()");
|
||||
}
|
||||
}
|
||||
"force_reload" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.eval("window.location.reload(true)");
|
||||
}
|
||||
}
|
||||
"toggle_devtools" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
}
|
||||
|
||||
"toggle_fullscreen" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||
}
|
||||
}
|
||||
|
||||
// Window menu
|
||||
"minimize" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.minimize();
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
// App menu (macOS)
|
||||
"about" => {
|
||||
// TODO: Implement about dialog
|
||||
println!("About menu item clicked");
|
||||
}
|
||||
"hide" => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
"hide_others" => {
|
||||
// TODO: Hide other app windows
|
||||
println!("Hide Others menu item clicked");
|
||||
}
|
||||
"show_all" => {
|
||||
// TODO: Show all app windows
|
||||
println!("Show All menu item clicked");
|
||||
}
|
||||
|
||||
_ => {
|
||||
println!("Unhandled menu event: {}", event.id().0);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
let is_mac = cfg!(target_os = "macos");
|
||||
|
||||
// Create submenus
|
||||
let mut submenus = Vec::new();
|
||||
|
||||
// App menu (macOS only)
|
||||
if is_mac {
|
||||
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
|
||||
.text("about", "About CodeNomad")
|
||||
.separator()
|
||||
.text("hide", "Hide CodeNomad")
|
||||
.text("hide_others", "Hide Others")
|
||||
.text("show_all", "Show All")
|
||||
.separator()
|
||||
.text("quit", "Quit CodeNomad")
|
||||
.build()?;
|
||||
submenus.push(app_menu);
|
||||
}
|
||||
|
||||
// File menu - create New Instance with accelerator
|
||||
let new_instance_item = MenuItem::with_id(
|
||||
app,
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
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()?;
|
||||
submenus.push(file_menu);
|
||||
|
||||
// Edit menu with predefined items for standard functionality
|
||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
.separator()
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.separator()
|
||||
.select_all()
|
||||
.build()?;
|
||||
submenus.push(edit_menu);
|
||||
|
||||
// View menu
|
||||
let view_menu = SubmenuBuilder::new(app, "View")
|
||||
.text("reload", "Reload")
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.separator()
|
||||
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.build()?;
|
||||
submenus.push(view_menu);
|
||||
|
||||
// Window menu
|
||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||
.text("minimize", "Minimize")
|
||||
.text("zoom", "Zoom")
|
||||
.build()?;
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
50
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
50
packages/tauri-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeNomad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.opencode.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev:bootstrap",
|
||||
"beforeBuildCommand": "npm run bundle:server",
|
||||
"frontendDist": "resources/ui-loading"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CodeNomad",
|
||||
"url": "loading.html",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"theme": "Dark",
|
||||
"backgroundColor": "#1a1a1a",
|
||||
"zoomHotkeysEnabled": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["**"]
|
||||
},
|
||||
"capabilities": ["main-window-native-dialogs"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"resources": [
|
||||
"resources/server",
|
||||
"resources/ui-loading"
|
||||
],
|
||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,29 @@ This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```bash
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
|
||||
## Debug Logging
|
||||
|
||||
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
|
||||
|
||||
- `sse` – Server-sent event transport and handlers
|
||||
- `api` – HTTP/API calls and workspace lifecycle
|
||||
- `session` – Session/model state, prompt handling, tool calls
|
||||
- `actions` – User-driven interactions in UI components
|
||||
|
||||
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
|
||||
|
||||
```js
|
||||
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
|
||||
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
|
||||
window.codenomadLogger?.disableLogger("sse") // turn them off again
|
||||
window.codenomadLogger?.enableAllLoggers() // optional helper
|
||||
```
|
||||
|
||||
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,11 +12,17 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@opencode-ai/sdk": "1.1.1",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
setHasInstances,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
} from "./stores/ui"
|
||||
@@ -39,22 +46,58 @@ import {
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
preferences,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch(console.error)
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
@@ -65,14 +108,30 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchErrorBinary()
|
||||
const value = launchError()?.binaryPath
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const isMissingBinaryError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return "Failed to launch workspace"
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
@@ -83,7 +142,7 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
@@ -95,17 +154,22 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
console.error("Failed to create instance:", error)
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
@@ -130,17 +194,24 @@ const App: Component = () => {
|
||||
try {
|
||||
await acknowledgeDisconnectedInstance()
|
||||
} catch (error) {
|
||||
console.error("Failed to finalize disconnected instance:", error)
|
||||
log.error("Failed to finalize disconnected instance", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
if (confirm("Stop OpenCode instance? This will stop the server.")) {
|
||||
await stopInstance(instanceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
const confirmed = await showConfirmDialog(
|
||||
"Stop OpenCode instance? This will stop the server.",
|
||||
{
|
||||
title: "Stop instance",
|
||||
variant: "warning",
|
||||
confirmLabel: "Stop",
|
||||
cancelLabel: "Keep running",
|
||||
},
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
await stopInstance(instanceId)
|
||||
}
|
||||
|
||||
async function handleNewSession(instanceId: string) {
|
||||
@@ -148,7 +219,7 @@ const App: Component = () => {
|
||||
const session = await createSession(instanceId)
|
||||
setActiveParentSession(instanceId, session.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +243,7 @@ const App: Component = () => {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh sessions after closing:", error)
|
||||
log.error("Failed to refresh sessions after closing", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +263,14 @@ const App: Component = () => {
|
||||
|
||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
handleNewInstanceRequest,
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
@@ -216,6 +291,28 @@ const App: Component = () => {
|
||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||
})
|
||||
|
||||
// Listen for Tauri menu events
|
||||
onMount(() => {
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
tauriBridge.event.listen("menu:newInstance", () => {
|
||||
handleNewInstanceRequest()
|
||||
}).then((unlisten) => {
|
||||
unlistenMenu = unlisten
|
||||
}).catch((error) => {
|
||||
log.error("Failed to listen for menu:newInstance event", error)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unlistenMenu?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstanceDisconnectedModal
|
||||
@@ -225,16 +322,16 @@ const App: Component = () => {
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<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-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||
Advanced Settings.
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||
binary from Advanced Settings.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
@@ -243,10 +340,23 @@ const App: Component = () => {
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
</button>
|
||||
@@ -266,22 +376,35 @@ const App: Component = () => {
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={activeInstance()} keyed>
|
||||
{(instance) => (
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -291,6 +414,7 @@ const App: Component = () => {
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -320,6 +444,10 @@ const App: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
<Toaster
|
||||
position="top-right"
|
||||
@@ -334,4 +462,5 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default App
|
||||
|
||||
@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import Kbd from "./kbd"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface AgentSelectorProps {
|
||||
instanceId: string
|
||||
@@ -50,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceAgents().length === 0) {
|
||||
fetchAgents(props.instanceId).catch(console.error)
|
||||
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const handleChange = async (value: Agent | null) => {
|
||||
if (value && value.name !== props.currentAgent) {
|
||||
await props.onAgentChange(value.name)
|
||||
@@ -111,14 +114,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
|
||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
<span class="hint sidebar-selector-hint">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
172
packages/ui/src/components/alert-dialog.tsx
Normal file
172
packages/ui/src/components/alert-dialog.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
||||
|
||||
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
|
||||
info: {
|
||||
badgeBg: "var(--badge-neutral-bg)",
|
||||
badgeBorder: "var(--border-base)",
|
||||
badgeText: "var(--accent-primary)",
|
||||
symbol: "i",
|
||||
fallbackTitle: "Heads up",
|
||||
},
|
||||
warning: {
|
||||
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||
badgeBorder: "var(--status-warning)",
|
||||
badgeText: "var(--status-warning)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Please review",
|
||||
},
|
||||
error: {
|
||||
badgeBg: "var(--danger-soft-bg)",
|
||||
badgeBorder: "var(--status-error)",
|
||||
badgeText: "var(--status-error)",
|
||||
symbol: "!",
|
||||
fallbackTitle: "Something went wrong",
|
||||
},
|
||||
}
|
||||
|
||||
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
|
||||
const current = payload ?? alertDialogState()
|
||||
|
||||
if (current?.type === "confirm") {
|
||||
if (confirmed) {
|
||||
current.onConfirm?.()
|
||||
} else {
|
||||
current.onCancel?.()
|
||||
}
|
||||
current.resolve?.(confirmed)
|
||||
dismissAlertDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (current?.type === "prompt") {
|
||||
if (confirmed) {
|
||||
current.onConfirm?.()
|
||||
current.resolvePrompt?.(promptValue ?? "")
|
||||
} else {
|
||||
current.onCancel?.()
|
||||
current.resolvePrompt?.(null)
|
||||
}
|
||||
dismissAlertDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
current?.onConfirm?.()
|
||||
}
|
||||
dismissAlertDialog()
|
||||
}
|
||||
|
||||
const AlertDialog: Component = () => {
|
||||
let primaryButtonRef: HTMLButtonElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (alertDialogState()) {
|
||||
queueMicrotask(() => {
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={alertDialogState()} keyed>
|
||||
{(payload) => {
|
||||
const variant = payload.variant ?? "info"
|
||||
const accent = variantAccent[variant]
|
||||
const title = payload.title || accent.fallbackTitle
|
||||
const isConfirm = payload.type === "confirm"
|
||||
const isPrompt = payload.type === "prompt"
|
||||
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
|
||||
const cancelLabel = payload.cancelLabel || "Cancel"
|
||||
|
||||
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
modal
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
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}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
"background-color": accent.badgeBg,
|
||||
"border-color": accent.badgeBorder,
|
||||
color: accent.badgeText,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{payload.inputLabel || "Arguments"}
|
||||
</label>
|
||||
<input
|
||||
class="modal-search-input mt-2"
|
||||
value={inputValue()}
|
||||
placeholder={payload.inputPlaceholder || ""}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
dismiss(true, payload, inputValue())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
{(isConfirm || isPrompt) && (
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
onClick={() => dismiss(false, payload)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary"
|
||||
ref={(el) => {
|
||||
primaryButtonRef = el
|
||||
}}
|
||||
onClick={() => dismiss(true, payload, inputValue())}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertDialog
|
||||
167
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
167
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
|
||||
interface BackgroundProcessOutputDialogProps {
|
||||
open: boolean
|
||||
instanceId: string
|
||||
process: BackgroundProcess | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||
const [output, setOutput] = createSignal("")
|
||||
const [outputHtml, setOutputHtml] = createSignal("")
|
||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
let ansiRenderer = createAnsiStreamRenderer()
|
||||
|
||||
createEffect(() => {
|
||||
const process = props.process
|
||||
if (!props.open || !process) {
|
||||
return
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let active = true
|
||||
|
||||
let rawOutput = ""
|
||||
|
||||
const setRawOutput = (next: string) => {
|
||||
rawOutput = next
|
||||
setOutput(next)
|
||||
}
|
||||
|
||||
const appendRawOutput = (chunk: string) => {
|
||||
rawOutput += chunk
|
||||
setOutput(rawOutput)
|
||||
}
|
||||
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
setRawOutput("")
|
||||
ansiRenderer.reset()
|
||||
|
||||
setLoading(true)
|
||||
serverApi
|
||||
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
|
||||
.then((response) => {
|
||||
if (!active) return
|
||||
|
||||
setRawOutput(response.content)
|
||||
setTruncated(response.truncated)
|
||||
|
||||
const detectedAnsi = hasAnsi(response.content)
|
||||
if (detectedAnsi) {
|
||||
setAnsiEnabled(true)
|
||||
ansiRenderer.reset()
|
||||
setOutputHtml(ansiRenderer.render(response.content))
|
||||
} else {
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
ansiRenderer.reset()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setRawOutput("Failed to load output.")
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = payload.content
|
||||
const wasAnsiEnabled = ansiEnabled()
|
||||
|
||||
if (!wasAnsiEnabled) {
|
||||
appendRawOutput(chunk)
|
||||
|
||||
if (hasAnsi(chunk)) {
|
||||
setAnsiEnabled(true)
|
||||
ansiRenderer.reset()
|
||||
setOutputHtml(ansiRenderer.render(rawOutput))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appendRawOutput(chunk)
|
||||
const htmlChunk = ansiRenderer.render(chunk)
|
||||
setOutputHtml((prev) => `${prev}${htmlChunk}`)
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
eventSource?.close()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
|
||||
<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-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
|
||||
<Show when={props.process}>
|
||||
<span class="text-xs text-secondary block">
|
||||
{props.process?.title} · {props.process?.id}
|
||||
</span>
|
||||
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
|
||||
{props.process?.command}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<Show when={loading()}>
|
||||
<p class="text-xs text-secondary">Loading output...</p>
|
||||
</Show>
|
||||
<Show when={!loading()}>
|
||||
<Show when={truncated()}>
|
||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={ansiEnabled()}
|
||||
fallback={
|
||||
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
|
||||
{output()}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
|
||||
innerHTML={outputHtml()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const success = await copyToClipboard(props.code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createMemo, Show, onMount, createEffect } from "solid-js"
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
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 { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
disableCache()
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
diffText: string
|
||||
@@ -13,7 +21,7 @@ interface ToolCallDiffViewerProps {
|
||||
mode: DiffViewMode
|
||||
onRendered?: () => void
|
||||
cachedHtml?: string
|
||||
cacheKey?: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
type DiffData = {
|
||||
@@ -22,16 +30,23 @@ type DiffData = {
|
||||
hunks: string[]
|
||||
}
|
||||
|
||||
type CaptureContext = {
|
||||
theme: ToolCallDiffViewerProps["theme"]
|
||||
mode: DiffViewMode
|
||||
diffText: string
|
||||
cacheEntryParams?: CacheEntryParams
|
||||
}
|
||||
|
||||
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
const diffData = createMemo<DiffData | null>(() => {
|
||||
const normalized = normalizeDiffText(props.diffText)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const language = getLanguageFromPath(props.filePath) || "text"
|
||||
const fileName = props.filePath || "diff"
|
||||
|
||||
|
||||
return {
|
||||
oldFile: {
|
||||
fileName,
|
||||
@@ -44,34 +59,48 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
hunks: [normalized],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
let diffContainerRef: HTMLDivElement | undefined
|
||||
|
||||
const captureAndCacheHtml = () => {
|
||||
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
|
||||
// Extract the rendered HTML from DiffView container
|
||||
const renderedHtml = diffContainerRef.innerHTML
|
||||
if (renderedHtml) {
|
||||
setToolRenderCache(props.cacheKey, {
|
||||
let lastCapturedKey: string | undefined
|
||||
|
||||
const contextKey = createMemo(() => {
|
||||
const data = diffData()
|
||||
if (!data) return ""
|
||||
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const cachedHtml = props.cachedHtml
|
||||
if (cachedHtml) {
|
||||
// When we are given cached HTML, we rely on the caller's cache
|
||||
// and simply notify once rendered.
|
||||
props.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
const key = contextKey()
|
||||
if (!key) return
|
||||
if (!diffContainerRef) return
|
||||
if (lastCapturedKey === key) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!diffContainerRef) return
|
||||
const markup = diffContainerRef.innerHTML
|
||||
if (!markup) return
|
||||
lastCapturedKey = key
|
||||
if (props.cacheEntryParams) {
|
||||
setCacheEntry(props.cacheEntryParams, {
|
||||
text: props.diffText,
|
||||
html: renderedHtml,
|
||||
html: markup,
|
||||
theme: props.theme,
|
||||
mode: props.mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
props.onRendered?.()
|
||||
}
|
||||
|
||||
// Also capture HTML when diff data changes
|
||||
createEffect(() => {
|
||||
const data = diffData()
|
||||
if (data && !props.cachedHtml) {
|
||||
// Delay to allow DiffView to re-render with new data
|
||||
setTimeout(captureAndCacheHtml, 100)
|
||||
}
|
||||
props.onRendered?.()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div class="tool-call-diff-viewer">
|
||||
<Show
|
||||
@@ -83,14 +112,19 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
||||
>
|
||||
{(data) => (
|
||||
<DiffView
|
||||
data={data()}
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
<ErrorBoundary fallback={(error) => {
|
||||
log.warn("Failed to render diff view", error)
|
||||
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
|
||||
}}>
|
||||
<DiffView
|
||||
data={data()}
|
||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||
diffViewTheme={props.theme}
|
||||
diffViewHighlight
|
||||
diffViewWrap={false}
|
||||
diffViewFontSize={13}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
@@ -172,7 +175,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
void fetchDirectory(path, true).catch((err) => {
|
||||
console.error("Failed to open directory", err)
|
||||
log.error("Failed to open directory", err)
|
||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
@@ -21,6 +24,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
const folders = () => recentFolders()
|
||||
@@ -29,9 +33,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
if (lastUsed && lastUsed !== selectedBinary()) {
|
||||
setSelectedBinary(lastUsed)
|
||||
}
|
||||
if (!lastUsed) return
|
||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||
})
|
||||
|
||||
|
||||
@@ -78,7 +81,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
if (isBrowseShortcut) {
|
||||
e.preventDefault()
|
||||
handleBrowse()
|
||||
void handleBrowse()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,9 +175,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
function handleBrowse() {
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: "Select Workspace",
|
||||
defaultPath: fallbackPath,
|
||||
})
|
||||
if (selected) {
|
||||
handleFolderSelect(selected)
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsFolderBrowserOpen(true)
|
||||
}
|
||||
|
||||
@@ -210,16 +224,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
@@ -229,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
@@ -306,14 +333,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={handleBrowse}
|
||||
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")}
|
||||
@@ -342,7 +369,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0">
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,26 @@
|
||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ParsedMcpStatus = {
|
||||
name: string
|
||||
status: "running" | "stopped" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
|
||||
if (!status || typeof status !== "object") return []
|
||||
|
||||
const result: ParsedMcpStatus[] = []
|
||||
|
||||
for (const [name, value] of Object.entries(status)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const rawStatus = (value as { status?: string }).status
|
||||
if (!rawStatus) continue
|
||||
|
||||
let mappedStatus: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") {
|
||||
mappedStatus = "running"
|
||||
} else if (rawStatus === "failed") {
|
||||
mappedStatus = "error"
|
||||
} else {
|
||||
mappedStatus = "stopped"
|
||||
}
|
||||
|
||||
result.push({
|
||||
name,
|
||||
status: mappedStatus,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const pendingMetadataRequests = new Set<string>()
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const metadata = () => props.instance.metadata
|
||||
const mcpServers = () => {
|
||||
const status = metadata()?.mcpStatus
|
||||
return status ? parseMcpStatus(status) : []
|
||||
}
|
||||
const lspServers = () => metadata()?.lspStatus ?? []
|
||||
|
||||
createEffect(() => {
|
||||
const instance = props.instance
|
||||
const instanceId = instance.id
|
||||
const client = instance.client
|
||||
const hasMetadata = Boolean(instance.metadata)
|
||||
|
||||
if (!client) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMetadata) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingMetadataRequests.has(instanceId)) {
|
||||
setIsLoadingMetadata(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
pendingMetadataRequests.add(instanceId)
|
||||
setIsLoadingMetadata(true)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
||||
client.project.current(),
|
||||
client.mcp.status(),
|
||||
fetchLspStatus(instanceId),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
||||
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
||||
|
||||
const nextMetadata = {
|
||||
...(instance.metadata ?? {}),
|
||||
...(project ? { project } : {}),
|
||||
...(mcpStatus ? { mcpStatus } : {}),
|
||||
...(lspStatus ? { lspStatus } : {}),
|
||||
}
|
||||
|
||||
if (!nextMetadata.version) {
|
||||
nextMetadata.version = "0.15.8"
|
||||
}
|
||||
|
||||
updateInstance(instanceId, { metadata: nextMetadata })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load instance metadata:", error)
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetadata(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
const environmentVariables = () => currentInstance().environmentVariables
|
||||
const environmentEntries = createMemo(() => {
|
||||
const env = environmentVariables()
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -135,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{props.instance.folder}
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,35 +70,35 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={metadata()?.version}>
|
||||
<Show when={binaryVersion()}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
OpenCode Version
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
v{metadata()?.version}
|
||||
v{binaryVersion()}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.binaryPath}>
|
||||
<Show when={currentInstance().binaryPath}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Binary Path
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{props.instance.binaryPath}
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
|
||||
<Show when={environmentEntries().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
|
||||
Environment Variables ({environmentEntries().length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(props.instance.environmentVariables!)}>
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div 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}>
|
||||
@@ -217,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
LSP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
MCP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div
|
||||
class={`status-dot ${
|
||||
server.status === "running"
|
||||
? "ready animate-pulse"
|
||||
: server.status === "error"
|
||||
? "error"
|
||||
: "stopped"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{
|
||||
server.status === "running"
|
||||
? "Connected"
|
||||
: server.status === "error"
|
||||
? "Error"
|
||||
: "Disabled"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={server.error}>
|
||||
{(error) => (
|
||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||
|
||||
<Show when={isLoadingMetadata()}>
|
||||
<div class="text-xs text-muted py-1">
|
||||
@@ -312,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Port:</span>
|
||||
<span class="text-primary font-mono">{props.instance.port}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">PID:</span>
|
||||
<span class="text-primary font-mono">{props.instance.pid}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Status:</span>
|
||||
<span
|
||||
class={`status-badge ${props.instance.status}`}
|
||||
>
|
||||
<span class={`status-badge ${currentInstance().status}`}>
|
||||
<div
|
||||
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
|
||||
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{props.instance.status}
|
||||
{currentInstance().status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
254
packages/ui/src/components/instance-service-status.tsx
Normal file
254
packages/ui/src/components/instance-service-status.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import Switch from "@suid/material/Switch"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ServiceSection = "lsp" | "mcp" | "plugins"
|
||||
|
||||
interface InstanceServiceStatusProps {
|
||||
sections?: ServiceSection[]
|
||||
showSectionHeadings?: boolean
|
||||
class?: string
|
||||
initialInstance?: Instance
|
||||
}
|
||||
|
||||
type ParsedMcpStatus = {
|
||||
name: string
|
||||
status: "running" | "stopped" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
||||
if (!status || typeof status !== "object") return []
|
||||
const result: ParsedMcpStatus[] = []
|
||||
for (const [name, value] of Object.entries(status)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const rawStatus = (value as { status?: string }).status
|
||||
if (!rawStatus) continue
|
||||
let mapped: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") mapped = "running"
|
||||
else if (rawStatus === "failed") mapped = "error"
|
||||
else mapped = "stopped"
|
||||
result.push({
|
||||
name,
|
||||
status: mapped,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const instance = metadataContext?.instance ?? (() => {
|
||||
if (props.initialInstance) {
|
||||
return props.initialInstance
|
||||
}
|
||||
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
|
||||
})
|
||||
const isLoading = metadataContext?.isLoading ?? (() => false)
|
||||
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
|
||||
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp", "plugins"])
|
||||
const includeLsp = createMemo(() => sections().includes("lsp"))
|
||||
const includeMcp = createMemo(() => sections().includes("mcp"))
|
||||
const includePlugins = createMemo(() => sections().includes("plugins"))
|
||||
const showHeadings = () => props.showSectionHeadings !== false
|
||||
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
|
||||
const metadata = createMemo(() => metadataAccessor())
|
||||
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
|
||||
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
|
||||
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
|
||||
|
||||
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
|
||||
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
|
||||
const plugins = createMemo(() => metadata()?.plugins ?? [])
|
||||
|
||||
const isLspLoading = () => isLoading() || !hasLspMetadata()
|
||||
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
|
||||
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
|
||||
|
||||
|
||||
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
|
||||
|
||||
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
|
||||
setPendingMcpActions((prev) => {
|
||||
const next = { ...prev }
|
||||
if (action) next[name] = action
|
||||
else delete next[name]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
|
||||
const client = instance().client
|
||||
if (!client?.mcp) return
|
||||
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
|
||||
setPendingMcpAction(serverName, action)
|
||||
try {
|
||||
if (shouldEnable) {
|
||||
await client.mcp.connect({ name: serverName })
|
||||
} else {
|
||||
await client.mcp.disconnect({ name: serverName })
|
||||
}
|
||||
await refreshMetadata()
|
||||
} catch (error) {
|
||||
log.error("Failed to toggle MCP server", { serverName, action, error })
|
||||
} finally {
|
||||
setPendingMcpAction(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEmptyState = (message: string) => (
|
||||
<p class="text-[11px] text-secondary italic" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
|
||||
const renderLspSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
LSP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isLspLoading() && lspServers().length > 0}
|
||||
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
const renderMcpSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
MCP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isMcpLoading() && mcpServers().length > 0}
|
||||
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => {
|
||||
const pendingAction = () => pendingMcpActions()[server.name]
|
||||
const isPending = () => Boolean(pendingAction())
|
||||
const isRunning = () => server.status === "running"
|
||||
const switchDisabled = () => isPending() || !instance().client
|
||||
const statusDotClass = () => {
|
||||
if (isPending()) return "status-dot animate-pulse"
|
||||
if (server.status === "running") return "status-dot ready animate-pulse"
|
||||
if (server.status === "error") return "status-dot error"
|
||||
return "status-dot stopped"
|
||||
}
|
||||
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
|
||||
return (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<Show when={isPending()}>
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<div class={statusDotClass()} style={statusDotStyle()} />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={isRunning()}
|
||||
disabled={switchDisabled()}
|
||||
color="success"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
||||
onChange={(_, checked) => {
|
||||
if (switchDisabled()) return
|
||||
void toggleMcpServer(server.name, Boolean(checked))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Show when={server.error}>
|
||||
{(error) => (
|
||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
const renderPluginsSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Plugins
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isPluginsLoading() && plugins().length > 0}
|
||||
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="text-xs text-primary font-medium break-words whitespace-normal">{plugin}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<Show when={includeLsp()}>{renderLspSection()}</Show>
|
||||
<Show when={includeMcp()}>{renderMcpSection()}</Show>
|
||||
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceServiceStatus
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { FolderOpen, X } from "lucide-solid"
|
||||
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
||||
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
||||
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
@@ -26,6 +27,24 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
||||
const statusClassName = createMemo(() => {
|
||||
const status = aggregatedStatus()
|
||||
return status === "permission" ? "session-permission" : `session-${status}`
|
||||
})
|
||||
const statusTitle = createMemo(() => {
|
||||
switch (aggregatedStatus()) {
|
||||
case "permission":
|
||||
return "Waiting on permission"
|
||||
case "compacting":
|
||||
return "Compacting"
|
||||
case "working":
|
||||
return "Working"
|
||||
default:
|
||||
return "Idle"
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="group">
|
||||
<button
|
||||
@@ -40,7 +59,18 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close ml-auto"
|
||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||
title={statusTitle()}
|
||||
aria-label={`Instance status: ${statusTitle()}`}
|
||||
>
|
||||
{aggregatedStatus() === "permission" ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus } from "lucide-solid"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
@@ -11,43 +11,60 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title="Remote connect"
|
||||
aria-label="Remote connect"
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
|
||||
interface InstanceWelcomeViewProps {
|
||||
@@ -16,8 +22,19 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
|
||||
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||
)
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const parentSessions = () => getParentSessions(props.instance.id)
|
||||
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instance.id)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
||||
const registered = keyboardRegistry.get("session-new")
|
||||
if (registered) return registered
|
||||
@@ -47,6 +64,12 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const openInstanceInfoOverlay = () => {
|
||||
if (isDesktopLayout()) return
|
||||
setShowInstanceInfoOverlay(true)
|
||||
}
|
||||
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||
if (element) {
|
||||
@@ -55,75 +78,170 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
let activeElement: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
activeElement = document.activeElement as HTMLElement | null
|
||||
}
|
||||
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||
const isEditingField =
|
||||
activeElement &&
|
||||
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
|
||||
activeElement.isContentEditable ||
|
||||
Boolean(insideModal))
|
||||
|
||||
if (isEditingField) {
|
||||
if (insideModal && e.key === "Escape" && renameTarget()) {
|
||||
e.preventDefault()
|
||||
closeRenameDialog()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showInstanceInfoOverlay()) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = parentSessions()
|
||||
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
handleNewSession()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
|
||||
const listFocused = focusMode() === "sessions"
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageDown") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!listFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = sessions.length - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
void handleEnterKey()
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
void handleDeleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleEnterKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
|
||||
if (index < sessions.length) {
|
||||
await handleSessionSelect(sessions[index].id)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)")
|
||||
const handleMediaChange = (matches: boolean) => {
|
||||
setIsDesktopLayout(matches)
|
||||
if (matches) {
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeEventListener("change", listener)
|
||||
})
|
||||
} else {
|
||||
mediaQuery.addListener(listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeListener(listener)
|
||||
})
|
||||
}
|
||||
|
||||
handleMediaChange(mediaQuery.matches)
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -144,15 +262,51 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
async function handleSessionDelete(sessionId: string) {
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
try {
|
||||
await deleteSession(props.instance.id, sessionId)
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
function openRenameDialogForSession(sessionId: string, title: string) {
|
||||
const label = title && title.trim() ? title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: title ?? "", label })
|
||||
}
|
||||
|
||||
function closeRenameDialog() {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
|
||||
async function handleRenameSubmit(nextTitle: string) {
|
||||
const target = renameTarget()
|
||||
if (!target) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await renameSession(props.instance.id, target.id, nextTitle)
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error("Failed to rename session:", error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
if (isCreating()) return
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const session = await createSession(props.instance.id)
|
||||
setActiveParentSession(props.instance.id, session.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session:", error)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@@ -160,78 +314,155 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<Show
|
||||
when={isFetchingSessions()}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||
<div>
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
</div>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary lg:hidden flex-shrink-0"
|
||||
onClick={openInstanceInfoOverlay}
|
||||
>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
||||
<For each={parentSessions()}>
|
||||
{(session, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group w-full"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(index())
|
||||
{(session, index) => {
|
||||
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": isFocused(),
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
class="text-sm font-medium text-primary truncate transition-colors"
|
||||
classList={{
|
||||
"text-accent":
|
||||
focusMode() === "sessions" && selectedIndex() === index(),
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group flex-1"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("sessions")
|
||||
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">
|
||||
<span
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
<span>{session.agent}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={isFocused()}>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Rename session"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openRenameDialogForSession(session.id, session.title || "")
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
</span>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Delete session"
|
||||
disabled={isSessionDeleting(session.id)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void handleSessionDelete(session.id)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(session.id)}
|
||||
fallback={
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
<span>{session.agent}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,14 +505,38 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={closeInstanceInfoOverlay}
|
||||
>
|
||||
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
|
||||
<div
|
||||
class="w-full max-w-md space-y-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
@@ -302,12 +557,23 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Resume</span>
|
||||
</div>
|
||||
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionRenameDialog
|
||||
open={Boolean(renameTarget())}
|
||||
currentTitle={renameTarget()?.title ?? ""}
|
||||
sessionLabel={renameTarget()?.label}
|
||||
isSubmitting={isRenaming()}
|
||||
onRename={handleRenameSubmit}
|
||||
onClose={closeRenameDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
escapeInDebounce: boolean
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
onNewSession: () => Promise<void> | void
|
||||
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
const sessionFamily = getSessionFamily(props.instance.id, parentId)
|
||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||
})
|
||||
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
return activeSessionMap().get(props.instance.id) || null
|
||||
})
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return activeSessions().get(sessionId) ?? null
|
||||
})
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||
|
||||
const keyboardShortcuts = createMemo(() =>
|
||||
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
||||
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
||||
),
|
||||
)
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
<Show
|
||||
when={activeSessionIdForInstance()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(sessionId) => (
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen()}
|
||||
onClose={() => hideCommandPalette(props.instance.id)}
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={props.onExecuteCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceShell
|
||||
1492
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
1492
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,32 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart } from "../types/message"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
|
||||
function resolvePartVersion(part: TextPart, text: string): string {
|
||||
if (typeof part.version === "number") {
|
||||
return String(part.version)
|
||||
}
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
@@ -19,17 +42,63 @@ export function Markdown(props: MarkdownProps) {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
}
|
||||
|
||||
createEffect(async () => {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const dark = Boolean(props.isDark)
|
||||
const themeKey = dark ? "dark" : "light"
|
||||
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 version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
await initMarkdown(dark)
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
return cache.theme === themeKey && cache.mode === version
|
||||
}
|
||||
|
||||
const localCache = part.renderCache
|
||||
if (localCache && cacheMatches(localCache)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
const globalCache = cacheHandle.get<RenderCache>()
|
||||
if (globalCache && cacheMatches(globalCache)) {
|
||||
setHtml(globalCache.html)
|
||||
part.renderCache = globalCache
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
if (!highlightEnabled) {
|
||||
part.renderCache = undefined
|
||||
@@ -38,40 +107,26 @@ export function Markdown(props: MarkdownProps) {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cache = part.renderCache
|
||||
if (cache && cache.text === text && cache.theme === themeKey) {
|
||||
setHtml(cache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
part.renderCache = { text, html: text, theme: themeKey }
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -86,13 +141,20 @@ export function Markdown(props: MarkdownProps) {
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
await navigator.clipboard.writeText(decodedCode)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
if (success) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = "Failed"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
containerRef?.addEventListener("click", handleClick)
|
||||
|
||||
// Register listener for language loading completion
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
@@ -117,13 +176,14 @@ export function Markdown(props: MarkdownProps) {
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||
setHtml(rendered)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to re-render markdown after language load:", error)
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
64
packages/ui/src/components/message-block-list.tsx
Normal file
64
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Index, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
export function getMessageAnchorId(messageId: string) {
|
||||
return `message-anchor-${messageId}`
|
||||
}
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
|
||||
interface MessageBlockListProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIds: () => string[]
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
suspendMeasurements?: () => boolean
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
{(messageId, index) => (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={index}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)}
|
||||
</Index>
|
||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
772
packages/ui/src/components/message-block.tsx
Normal file
772
packages/ui/src/components/message-block.tsx
Normal file
@@ -0,0 +1,772 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { FoldVertical } 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"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||
return Boolean(state && state.status === "running")
|
||||
}
|
||||
|
||||
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
|
||||
return Boolean(state && state.status === "completed")
|
||||
}
|
||||
|
||||
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
|
||||
return Boolean(state && state.status === "error")
|
||||
}
|
||||
|
||||
function extractTaskSessionId(state: ToolState | undefined): string {
|
||||
if (!state) return ""
|
||||
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
|
||||
const directId = metadata?.sessionId ?? metadata?.sessionID
|
||||
return typeof directId === "string" ? directId : ""
|
||||
}
|
||||
|
||||
function reasoningHasRenderableContent(part: ClientPart): boolean {
|
||||
if (!part || part.type !== "reasoning") {
|
||||
return false
|
||||
}
|
||||
const checkSegment = (segment: unknown): boolean => {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(candidate.content)) {
|
||||
return candidate.content.some((entry) => checkSegment(entry))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkSegment((part as any).text)) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray((part as any).content)) {
|
||||
return (part as any).content.some((entry: unknown) => checkSegment(entry))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface TaskSessionLocation {
|
||||
sessionId: string
|
||||
instanceId: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||
if (!sessionId) return null
|
||||
const allSessions = sessions()
|
||||
for (const [instanceId, sessionMap] of allSessions) {
|
||||
const session = sessionMap?.get(sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
instanceId,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
setActiveSession(location.instanceId, location.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedBlockEntry {
|
||||
signature: string
|
||||
block: MessageDisplayBlock
|
||||
contentKeys: string[]
|
||||
toolKeys: string[]
|
||||
}
|
||||
|
||||
interface SessionRenderCache {
|
||||
messageItems: Map<string, ContentDisplayItem>
|
||||
toolItems: Map<string, ToolDisplayItem>
|
||||
messageBlocks: Map<string, CachedBlockEntry>
|
||||
}
|
||||
|
||||
const renderCaches = new Map<string, SessionRenderCache>()
|
||||
|
||||
function makeSessionCacheKey(instanceId: string, sessionId: string) {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
|
||||
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
|
||||
}
|
||||
|
||||
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
|
||||
const key = makeSessionCacheKey(instanceId, sessionId)
|
||||
let cache = renderCaches.get(key)
|
||||
if (!cache) {
|
||||
cache = {
|
||||
messageItems: new Map(),
|
||||
toolItems: new Map(),
|
||||
messageBlocks: new Map(),
|
||||
}
|
||||
renderCaches.set(key, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
function clearInstanceCaches(instanceId: string) {
|
||||
clearRecordDisplayCacheForInstance(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of renderCaches.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
renderCaches.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||
|
||||
interface ContentDisplayItem {
|
||||
type: "content"
|
||||
key: string
|
||||
record: MessageRecord
|
||||
parts: ClientPart[]
|
||||
messageInfo?: MessageInfo
|
||||
isQueued: boolean
|
||||
showAgentMeta?: boolean
|
||||
}
|
||||
|
||||
interface ToolDisplayItem {
|
||||
type: "tool"
|
||||
key: string
|
||||
toolPart: ToolCallPart
|
||||
messageInfo?: MessageInfo
|
||||
messageId: string
|
||||
messageVersion: number
|
||||
partVersion: number
|
||||
}
|
||||
|
||||
interface StepDisplayItem {
|
||||
type: "step-start" | "step-finish"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
type ReasoningDisplayItem = {
|
||||
type: "reasoning"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded: boolean
|
||||
}
|
||||
|
||||
type CompactionDisplayItem = {
|
||||
type: "compaction"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
|
||||
|
||||
interface MessageDisplayBlock {
|
||||
record: MessageRecord
|
||||
items: MessageBlockItem[]
|
||||
}
|
||||
|
||||
interface MessageBlockProps {
|
||||
messageId: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessageBlock(props: MessageBlockProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
|
||||
const block = createMemo<MessageDisplayBlock | null>(() => {
|
||||
const current = record()
|
||||
if (!current) return null
|
||||
|
||||
const index = props.messageIndex
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||
const infoTimestamp =
|
||||
typeof infoTime.completed === "number"
|
||||
? infoTime.completed
|
||||
: typeof infoTime.updated === "number"
|
||||
? infoTime.updated
|
||||
: infoTime.created ?? 0
|
||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||
const cacheSignature = [
|
||||
current.id,
|
||||
current.revision,
|
||||
isQueued ? 1 : 0,
|
||||
props.showThinking() ? 1 : 0,
|
||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||
props.showUsageMetrics() ? 1 : 0,
|
||||
infoTimestamp,
|
||||
infoErrorName,
|
||||
].join("|")
|
||||
|
||||
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||
if (cachedBlock && cachedBlock.signature === cacheSignature) {
|
||||
return cachedBlock.block
|
||||
}
|
||||
|
||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||
const items: MessageBlockItem[] = []
|
||||
const blockContentKeys: string[] = []
|
||||
const blockToolKeys: string[] = []
|
||||
let segmentIndex = 0
|
||||
let pendingParts: ClientPart[] = []
|
||||
let agentMetaAttached = current.role !== "assistant"
|
||||
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
|
||||
let lastAccentColor = defaultAccentColor
|
||||
|
||||
const flushContent = () => {
|
||||
if (pendingParts.length === 0) return
|
||||
const segmentKey = `${current.id}:segment:${segmentIndex}`
|
||||
segmentIndex += 1
|
||||
const shouldShowAgentMeta =
|
||||
current.role === "assistant" &&
|
||||
!agentMetaAttached &&
|
||||
pendingParts.some((part) => partHasRenderableText(part))
|
||||
let cached = sessionCache.messageItems.get(segmentKey)
|
||||
if (!cached) {
|
||||
cached = {
|
||||
type: "content",
|
||||
key: segmentKey,
|
||||
record: current,
|
||||
parts: pendingParts.slice(),
|
||||
messageInfo: info,
|
||||
isQueued,
|
||||
showAgentMeta: shouldShowAgentMeta,
|
||||
}
|
||||
sessionCache.messageItems.set(segmentKey, cached)
|
||||
} else {
|
||||
cached.record = current
|
||||
cached.parts = pendingParts.slice()
|
||||
cached.messageInfo = info
|
||||
cached.isQueued = isQueued
|
||||
cached.showAgentMeta = shouldShowAgentMeta
|
||||
}
|
||||
if (shouldShowAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
items.push(cached)
|
||||
blockContentKeys.push(segmentKey)
|
||||
lastAccentColor = defaultAccentColor
|
||||
pendingParts = []
|
||||
}
|
||||
|
||||
orderedParts.forEach((part, partIndex) => {
|
||||
if (part.type === "tool") {
|
||||
flushContent()
|
||||
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
|
||||
const messageVersion = current.revision
|
||||
const key = `${current.id}:${part.id ?? partIndex}`
|
||||
let toolItem = sessionCache.toolItems.get(key)
|
||||
if (!toolItem) {
|
||||
toolItem = {
|
||||
type: "tool",
|
||||
key,
|
||||
toolPart: part as ToolCallPart,
|
||||
messageInfo: info,
|
||||
messageId: current.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
sessionCache.toolItems.set(key, toolItem)
|
||||
} else {
|
||||
toolItem.key = key
|
||||
toolItem.toolPart = part as ToolCallPart
|
||||
toolItem.messageInfo = info
|
||||
toolItem.messageId = current.id
|
||||
toolItem.messageVersion = messageVersion
|
||||
toolItem.partVersion = partVersion
|
||||
}
|
||||
items.push(toolItem)
|
||||
blockToolKeys.push(key)
|
||||
lastAccentColor = TOOL_BORDER_COLOR
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushContent()
|
||||
const key = `${current.id}:${part.id ?? partIndex}:compaction`
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
items.push({
|
||||
type: "compaction",
|
||||
key,
|
||||
part,
|
||||
messageInfo: info,
|
||||
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
|
||||
})
|
||||
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
flushContent()
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "step-finish") {
|
||||
flushContent()
|
||||
if (props.showUsageMetrics()) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
|
||||
const accentColor = lastAccentColor || defaultAccentColor
|
||||
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
|
||||
lastAccentColor = accentColor
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
flushContent()
|
||||
if (props.showThinking() && reasoningHasRenderableContent(part)) {
|
||||
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
|
||||
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
|
||||
if (showAgentMeta) {
|
||||
agentMetaAttached = true
|
||||
}
|
||||
items.push({
|
||||
type: "reasoning",
|
||||
key,
|
||||
part,
|
||||
messageInfo: info,
|
||||
showAgentMeta,
|
||||
defaultExpanded: props.thinkingDefaultExpanded(),
|
||||
})
|
||||
lastAccentColor = ASSISTANT_BORDER_COLOR
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingParts.push(part)
|
||||
})
|
||||
|
||||
flushContent()
|
||||
|
||||
const resultBlock: MessageDisplayBlock = { record: current, items }
|
||||
sessionCache.messageBlocks.set(current.id, {
|
||||
signature: cacheSignature,
|
||||
block: resultBlock,
|
||||
contentKeys: blockContentKeys.slice(),
|
||||
toolKeys: blockToolKeys.slice(),
|
||||
})
|
||||
|
||||
const messagePrefix = `${current.id}:`
|
||||
for (const [key] of sessionCache.messageItems) {
|
||||
if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) {
|
||||
sessionCache.messageItems.delete(key)
|
||||
}
|
||||
}
|
||||
for (const [key] of sessionCache.toolItems) {
|
||||
if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) {
|
||||
sessionCache.toolItems.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return resultBlock
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={block()} keyed>
|
||||
{(resolvedBlock) => (
|
||||
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
|
||||
<For each={resolvedBlock.items}>
|
||||
{(item) => (
|
||||
<Switch>
|
||||
<Match when={item.type === "content"}>
|
||||
<MessageItem
|
||||
record={(item as ContentDisplayItem).record}
|
||||
messageInfo={(item as ContentDisplayItem).messageInfo}
|
||||
parts={(item as ContentDisplayItem).parts}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={(item as ContentDisplayItem).isQueued}
|
||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "tool"}>
|
||||
{(() => {
|
||||
const toolItem = item as ToolDisplayItem
|
||||
const toolState = toolItem.toolPart.state as ToolState | undefined
|
||||
const hasToolState =
|
||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!taskLocation) return
|
||||
navigateToTaskSession(taskLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={toolItem.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolItem.toolPart}
|
||||
toolCallId={toolItem.toolPart.id}
|
||||
messageId={toolItem.messageId}
|
||||
messageVersion={toolItem.messageVersion}
|
||||
partVersion={toolItem.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
kind="finish"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showUsage={props.showUsageMetrics()}
|
||||
borderColor={(item as StepDisplayItem).accentColor}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
part={(item as ReasoningDisplayItem).part}
|
||||
messageInfo={(item as ReasoningDisplayItem).messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepCardProps {
|
||||
kind: "start" | "finish"
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
showAgentMeta?: boolean
|
||||
showUsage?: boolean
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
return (
|
||||
<div
|
||||
class={containerClass()}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label="Session compaction"
|
||||
>
|
||||
<div class="message-compaction-row">
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (!props.showAgentMeta) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
return info.mode || ""
|
||||
}
|
||||
|
||||
const modelIdentifier = () => {
|
||||
if (!props.showAgentMeta) return ""
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
const usageStats = () => {
|
||||
if (props.kind !== "finish" || !props.showUsage) {
|
||||
return null
|
||||
}
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||
return null
|
||||
}
|
||||
const tokens = info.tokens
|
||||
return {
|
||||
input: tokens.input ?? 0,
|
||||
output: tokens.output ?? 0,
|
||||
reasoning: tokens.reasoning ?? 0,
|
||||
cacheRead: tokens.cache?.read ?? 0,
|
||||
cacheWrite: tokens.cache?.write ?? 0,
|
||||
cost: info.cost ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||
|
||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||
const entries = [
|
||||
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
||||
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
||||
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
||||
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
||||
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
||||
]
|
||||
|
||||
return (
|
||||
<div class="message-step-usage">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<span class="message-step-usage-chip" data-label={entry.label}>
|
||||
{entry.formatter(entry.value)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.kind === "finish") {
|
||||
const usage = usageStats()
|
||||
if (!usage) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
|
||||
{renderUsageChips(usage)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`message-step-card message-step-start`}>
|
||||
<div class="message-step-heading">
|
||||
<div class="message-step-title">
|
||||
<div class="message-step-title-left">
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="message-step-time">{timestamp()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCostValue(value: number) {
|
||||
if (!value) return "$0.00"
|
||||
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
interface ReasoningCardProps {
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showAgentMeta?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
function ReasoningCard(props: ReasoningCardProps) {
|
||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
})
|
||||
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const agentIdentifier = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
return info.mode || ""
|
||||
}
|
||||
|
||||
const modelIdentifier = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info || info.role !== "assistant") return ""
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
const reasoningText = () => {
|
||||
const part = props.part as any
|
||||
if (!part) return ""
|
||||
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
return segment
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const pieces: string[] = []
|
||||
if (typeof obj.text === "string") {
|
||||
pieces.push(obj.text)
|
||||
}
|
||||
if (typeof obj.value === "string") {
|
||||
pieces.push(obj.value)
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||
}
|
||||
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const textValue = stringifySegment(part.text)
|
||||
if (textValue.trim().length > 0) {
|
||||
return textValue
|
||||
}
|
||||
if (Array.isArray(part.content)) {
|
||||
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev)
|
||||
|
||||
return (
|
||||
<div class="message-reasoning-card">
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
|
||||
>
|
||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||
<span>Thinking</span>
|
||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="message-reasoning-meta">
|
||||
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
|
||||
<span class="message-reasoning-time">{timestamp()}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,47 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
record: MessageRecord
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts?: ClientPart[]
|
||||
parts: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
const date = new Date(createdTimestamp())
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const timestampIso = () => new Date(createdTimestamp()).toISOString()
|
||||
|
||||
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
||||
url?: string
|
||||
mime?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const displayParts = () => props.parts ?? props.message.parts
|
||||
const messageParts = () => props.parts
|
||||
|
||||
const fileAttachments = () =>
|
||||
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
|
||||
const getAttachmentName = (part: FilePart) => {
|
||||
if (part.filename && part.filename.trim().length > 0) {
|
||||
@@ -120,7 +131,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return displayParts().some((part) => partHasRenderableText(part))
|
||||
return messageParts().some((part) => partHasRenderableText(part))
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
@@ -130,15 +141,37 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
|
||||
const handleRevert = () => {
|
||||
if (props.onRevert && isUser()) {
|
||||
props.onRevert(props.message.id)
|
||||
props.onRevert(props.record.id)
|
||||
}
|
||||
}
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => (part as { text?: string }).text || "")
|
||||
.filter(text => text.trim().length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
const success = await copyToClipboard(content)
|
||||
setCopied(success)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerClass = () =>
|
||||
isUser()
|
||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||
|
||||
|
||||
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
||||
|
||||
const agentIdentifier = () => {
|
||||
if (isUser()) return ""
|
||||
const info = props.messageInfo
|
||||
@@ -155,50 +188,86 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
}
|
||||
|
||||
|
||||
const agentMeta = () => {
|
||||
if (isUser() || !props.showAgentMeta) return ""
|
||||
const segments: string[] = []
|
||||
const agent = agentIdentifier()
|
||||
const model = modelIdentifier()
|
||||
if (agent) {
|
||||
segments.push(`Agent: ${agent}`)
|
||||
}
|
||||
if (model) {
|
||||
segments.push(`Model: ${model}`)
|
||||
}
|
||||
return segments.join(" • ")
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div class={containerClass()}>
|
||||
<div class="flex justify-between items-center gap-2.5 pb-0.5">
|
||||
<div class="flex flex-col">
|
||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||
<div class="message-speaker">
|
||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||
{speakerLabel()}
|
||||
</span>
|
||||
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
||||
</div>
|
||||
<div class="message-item-actions">
|
||||
<Show when={isUser()}>
|
||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
||||
</Show>
|
||||
|
||||
<Show when={!isUser()}>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
|
||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
||||
<div class="message-action-group">
|
||||
<Show when={props.onRevert}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleRevert}
|
||||
title="Revert to this message"
|
||||
aria-label="Revert to this message"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={() => props.onFork?.(props.record.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isUser() && props.onRevert}>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={handleRevert}
|
||||
title="Revert to this message"
|
||||
aria-label="Revert to this message"
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
Revert to
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isUser() && props.onFork}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
onClick={() => props.onFork?.(props.message.id)}
|
||||
title="Fork from this message"
|
||||
aria-label="Fork from this message"
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
<div class="message-queued-badge">QUEUED</div>
|
||||
</Show>
|
||||
@@ -213,72 +282,72 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayParts()}>
|
||||
<For each={messageParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
messageType={props.record.role}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
<div class="message-attachments mt-1">
|
||||
<For each={fileAttachments()}>
|
||||
{(attachment) => {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
}>
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={`Download ${name}`}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.record.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.record.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
<div class="message-attachments">
|
||||
<For each={fileAttachments()}>
|
||||
{(attachment) => {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
}>
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={`Download ${name}`}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
85
packages/ui/src/components/message-list-header.tsx
Normal file
85
packages/ui/src/components/message-list-header.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
availableTokens?: number | null
|
||||
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||
onCommandPalette: () => void
|
||||
formatTokens: (value: number) => string
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
}
|
||||
|
||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
|
||||
return (
|
||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||
<Show when={props.showSidebarToggle}>
|
||||
<div class="connection-status-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button"
|
||||
onClick={() => props.onSidebarToggle?.()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="connection-status-text connection-status-info">
|
||||
<div class="connection-status-usage">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={props.connectionStatus === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,10 @@ interface MessagePartProps {
|
||||
messageType?: "user" | "assistant"
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
onRendered?: () => void
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
const partType = () => props.part?.type || ""
|
||||
@@ -33,6 +35,38 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||
if (typeof segment === "string") {
|
||||
return segment.trim().length > 0
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(candidate.content)) {
|
||||
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hasReasoningContent = () => {
|
||||
if (props.part?.type !== "reasoning") {
|
||||
return false
|
||||
}
|
||||
if (reasoningSegmentHasText((props.part as any).text)) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray((props.part as any).content)) {
|
||||
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createTextPartForMarkdown = (): TextPart => {
|
||||
const part = props.part
|
||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
||||
@@ -63,11 +97,19 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
|
||||
<div class={textContainerClass()}>
|
||||
<Show
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
||||
</Show>
|
||||
when={isAssistantMessage()}
|
||||
fallback={<span>{plainTextContent()}</span>}
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -83,23 +125,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
|
||||
|
||||
<Match when={partType() === "reasoning"}>
|
||||
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
|
||||
<div class="message-reasoning">
|
||||
<div class="reasoning-container">
|
||||
<div class="reasoning-header" onClick={handleReasoningClick}>
|
||||
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
|
||||
<span class="reasoning-label">Reasoning</span>
|
||||
</div>
|
||||
<Show when={isReasoningExpanded()}>
|
||||
<div class={`${textContainerClass()} mt-2`}>
|
||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
32
packages/ui/src/components/message-preview.tsx
Normal file
32
packages/ui/src/components/message-preview.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
interface MessagePreviewProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
store: () => InstanceMessageStore
|
||||
}
|
||||
|
||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
const lastAssistantIndex = () => 0
|
||||
|
||||
return (
|
||||
<div class="message-preview message-stream">
|
||||
<MessageBlock
|
||||
messageId={props.messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={0}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
showUsageMetrics={() => false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessagePreview
|
||||
858
packages/ui/src/components/message-section.tsx
Normal file
858
packages/ui/src/components/message-section.tsx
Normal file
@@ -0,0 +1,858 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
export interface MessageSectionProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
onQuoteSelection?: (text: string, mode: "quote" | "code") => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = usageSnapshot()
|
||||
const info = sessionInfo()
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||
avail: info.contextAvailableTokens,
|
||||
}
|
||||
})
|
||||
|
||||
const preferenceSignature = createMemo(() => {
|
||||
const pref = preferences()
|
||||
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||
})
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
}
|
||||
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
ids.forEach((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
segments.push(segment)
|
||||
})
|
||||
})
|
||||
setTimelineSegments(segments)
|
||||
}
|
||||
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
}
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
const isActive = createMemo(() => props.isActive !== false)
|
||||
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: SCROLL_SCOPE,
|
||||
})
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||
const bottomSentinel = () => bottomSentinelSignal()
|
||||
const setBottomSentinel = (element: HTMLDivElement | null) => {
|
||||
setBottomSentinelSignal(element)
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let shellRef: HTMLDivElement | undefined
|
||||
let pendingScrollFrame: number | null = null
|
||||
|
||||
let pendingAnchorScroll: number | null = null
|
||||
|
||||
let pendingScrollPersist: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let hasRestoredScroll = false
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingActiveScroll = false
|
||||
let scrollToBottomFrame: number | null = null
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
let pendingInitialScroll = true
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (!element) return
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
setScrollElement(containerRef)
|
||||
attachScrollIntentListeners(containerRef)
|
||||
if (!containerRef) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
resolvePendingActiveScroll()
|
||||
}
|
||||
|
||||
function setShellElement(element: HTMLDivElement | null) {
|
||||
shellRef = element || undefined
|
||||
if (!shellRef) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function updateScrollIndicatorsFromVisibility() {
|
||||
|
||||
const hasItems = messageIds().length > 0
|
||||
const bottomVisible = bottomSentinelVisible()
|
||||
const topVisible = topSentinelVisible()
|
||||
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||
setShowScrollTopButton(hasItems && !topVisible)
|
||||
}
|
||||
|
||||
function scheduleScrollPersist() {
|
||||
if (pendingScrollPersist !== null) return
|
||||
pendingScrollPersist = requestAnimationFrame(() => {
|
||||
pendingScrollPersist = null
|
||||
if (!containerRef) return
|
||||
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||
if (!containerRef) return
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||
if (suppressAutoAnchor) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
setAutoScroll(true)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
function clearScrollToBottomFrames() {
|
||||
if (scrollToBottomFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomFrame)
|
||||
scrollToBottomFrame = null
|
||||
}
|
||||
if (scrollToBottomDelayedFrame !== null) {
|
||||
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||
scrollToBottomDelayedFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function requestScrollToBottom(immediate = true) {
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (!containerRef || !bottomSentinel()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
pendingActiveScroll = false
|
||||
clearScrollToBottomFrames()
|
||||
scrollToBottomFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomFrame = null
|
||||
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
|
||||
scrollToBottomDelayedFrame = null
|
||||
scrollToBottom(immediate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolvePendingActiveScroll() {
|
||||
if (!pendingActiveScroll) return
|
||||
if (!isActive()) return
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
setAutoScroll(false)
|
||||
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
function clearQuoteSelection() {
|
||||
setQuoteSelection(null)
|
||||
}
|
||||
|
||||
function isSelectionWithinStream(range: Range | null) {
|
||||
if (!range || !containerRef) return false
|
||||
const node = range.commonAncestorContainer
|
||||
if (!node) return false
|
||||
return containerRef.contains(node)
|
||||
}
|
||||
|
||||
function updateQuoteSelectionFromSelection() {
|
||||
if (!props.onQuoteSelection || typeof window === "undefined") {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!isSelectionWithinStream(range)) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const shell = shellRef
|
||||
if (!shell) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const rawText = selection.toString().trim()
|
||||
if (!rawText) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const limited =
|
||||
rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText
|
||||
if (!limited) {
|
||||
clearQuoteSelection()
|
||||
return
|
||||
}
|
||||
const rects = range.getClientRects()
|
||||
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||
const shellRect = shell.getBoundingClientRect()
|
||||
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||
}
|
||||
|
||||
function handleStreamMouseUp() {
|
||||
updateQuoteSelectionFromSelection()
|
||||
}
|
||||
|
||||
function handleQuoteSelectionRequest(mode: "quote" | "code") {
|
||||
const info = quoteSelection()
|
||||
if (!info || !props.onQuoteSelection) return
|
||||
props.onQuoteSelection(info.text, mode)
|
||||
clearQuoteSelection()
|
||||
if (typeof window !== "undefined") {
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (props.loading) {
|
||||
return
|
||||
}
|
||||
scheduleAnchorScroll()
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const atBottom = bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
clearQuoteSelection()
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => requestScrollToBottom(true))
|
||||
}
|
||||
})
|
||||
|
||||
let lastActiveState = false
|
||||
createEffect(() => {
|
||||
const active = isActive()
|
||||
if (active) {
|
||||
resolvePendingActiveScroll()
|
||||
if (!lastActiveState && autoScroll()) {
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
} else if (autoScroll()) {
|
||||
pendingActiveScroll = true
|
||||
}
|
||||
lastActiveState = active
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
if (loading) {
|
||||
pendingInitialScroll = true
|
||||
return
|
||||
}
|
||||
if (!pendingInitialScroll) {
|
||||
return
|
||||
}
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || messageIds().length === 0) {
|
||||
return
|
||||
}
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
}
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.onQuoteSelection) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const handleSelectionChange = () => updateQuoteSelectionFromSelection()
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!shellRef) return
|
||||
if (!shellRef.contains(event.target as Node)) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
}
|
||||
document.addEventListener("selectionchange", handleSelectionChange)
|
||||
document.addEventListener("pointerdown", handlePointerDown)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||
document.removeEventListener("pointerdown", handlePointerDown)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) {
|
||||
clearQuoteSelection()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const target = containerRef
|
||||
const loading = props.loading
|
||||
if (!target || loading || hasRestoredScroll) return
|
||||
|
||||
|
||||
// scrollCache.restore(target, {
|
||||
// onApplied: (snapshot) => {
|
||||
// if (snapshot) {
|
||||
// setAutoScroll(snapshot.atBottom)
|
||||
// } else {
|
||||
// setAutoScroll(bottomSentinelVisible())
|
||||
// }
|
||||
// updateScrollIndicatorsFromVisibility()
|
||||
// },
|
||||
// })
|
||||
|
||||
hasRestoredScroll = true
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const loading = props.loading
|
||||
if (loading || !token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
previousToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scheduleAnchorScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
preferenceSignature()
|
||||
if (props.loading || !autoScroll()) {
|
||||
return
|
||||
}
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (messageIds().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
return
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
})
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const topTarget = topSentinel()
|
||||
const bottomTarget = bottomSentinel()
|
||||
if (!container || !topTarget || !bottomTarget) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let visibilityChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.target === topTarget) {
|
||||
setTopSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
} else if (entry.target === bottomTarget) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
}
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `${SCROLL_SENTINEL_MARGIN_PX}px 0px ${SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||
)
|
||||
observer.observe(topTarget)
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const ids = messageIds()
|
||||
if (!container || ids.length === 0) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const anchorId = (best.target as HTMLElement).id
|
||||
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId
|
||||
setActiveMessageId((current) => (current === messageId ? current : messageId))
|
||||
}
|
||||
},
|
||||
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||
)
|
||||
|
||||
ids.forEach((messageId) => {
|
||||
const anchor = document.getElementById(getMessageAnchorId(messageId))
|
||||
if (anchor) {
|
||||
observer.observe(anchor)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
if (pendingScrollPersist !== null) {
|
||||
cancelAnimationFrame(pendingScrollPersist)
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
if (containerRef) {
|
||||
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
}
|
||||
clearQuoteSelection()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={quoteSelection()}>
|
||||
{(selection) => (
|
||||
<div
|
||||
class="message-quote-popover"
|
||||
style={{ top: `${selection().top}px`, left: `${selection().left}px` }}
|
||||
>
|
||||
<div class="message-quote-button-group">
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||
Add as quote
|
||||
</button>
|
||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||
Add as code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={hasTimelineSegments()}>
|
||||
<div class="message-timeline-sidebar">
|
||||
<MessageTimeline
|
||||
segments={timelineSegments()}
|
||||
onSegmentClick={handleTimelineSegmentClick}
|
||||
activeMessageId={activeMessageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
showToolSegments={showTimelineToolsPreference()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
// Import ToolState types from SDK
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
// Type guards
|
||||
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
|
||||
return state.status === "running"
|
||||
}
|
||||
|
||||
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
|
||||
return state.status === "completed"
|
||||
}
|
||||
|
||||
function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
return state.status === "error"
|
||||
}
|
||||
|
||||
// Type guard to check if a part is a tool part
|
||||
function isToolPart(part: ClientPart): part is ToolCallPart {
|
||||
return part.type === "tool"
|
||||
}
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import Kbd from "./kbd"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const SCROLL_OFFSET = 64
|
||||
const SCROLL_DIRECTION_THRESHOLD = 10
|
||||
|
||||
interface TaskSessionLocation {
|
||||
sessionId: string
|
||||
instanceId: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
const messageScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||
if (!sessionId) return null
|
||||
const allSessions = sessions()
|
||||
for (const [instanceId, sessionMap] of allSessions) {
|
||||
const session = sessionMap?.get(sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
instanceId,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
setActiveInstanceId(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
setActiveSession(location.instanceId, location.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Format tokens like TUI (e.g., "110K", "1.2M")
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(0)}K`
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
// Format session info for the session view header
|
||||
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
|
||||
if (contextWindow > 0) {
|
||||
const windowStr = formatTokens(contextWindow)
|
||||
const usageStr = formatTokens(usageTokens)
|
||||
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
|
||||
return `${usageStr} of ${windowStr} (${percent}%)`
|
||||
}
|
||||
|
||||
return formatTokens(usageTokens)
|
||||
}
|
||||
|
||||
interface MessageStreamProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messages: Message[]
|
||||
messagesInfo?: Map<string, MessageInfo>
|
||||
revert?: {
|
||||
messageID: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
}
|
||||
|
||||
interface MessageDisplayItem {
|
||||
type: "message"
|
||||
message: Message
|
||||
combinedParts: ClientPart[]
|
||||
isQueued: boolean
|
||||
messageInfo?: MessageInfo
|
||||
}
|
||||
|
||||
interface ToolDisplayItem {
|
||||
type: "tool"
|
||||
key: string
|
||||
toolPart: ToolCallPart
|
||||
messageInfo?: MessageInfo
|
||||
messageId: string
|
||||
messageVersion: number
|
||||
partVersion: number
|
||||
}
|
||||
|
||||
type DisplayItem = MessageDisplayItem | ToolDisplayItem
|
||||
|
||||
interface MessageCacheEntry {
|
||||
message: Message
|
||||
version: number
|
||||
showThinking: boolean
|
||||
isQueued: boolean
|
||||
messageInfo?: MessageInfo
|
||||
displayParts: MessageDisplayParts
|
||||
item: MessageDisplayItem
|
||||
}
|
||||
|
||||
interface ToolCacheEntry {
|
||||
toolPart: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
signature: string
|
||||
contentKey: string
|
||||
item: ToolDisplayItem
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface SessionCache {
|
||||
messageItemCache: Map<string, MessageCacheEntry>
|
||||
toolItemCache: Map<string, ToolCacheEntry>
|
||||
}
|
||||
|
||||
const sessionCaches = new Map<string, SessionCache>()
|
||||
|
||||
function getSessionCache(instanceId: string, sessionId: string): SessionCache {
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
let cache = sessionCaches.get(key)
|
||||
if (!cache) {
|
||||
cache = {
|
||||
messageItemCache: new Map(),
|
||||
toolItemCache: new Map(),
|
||||
}
|
||||
sessionCaches.set(key, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
export default function MessageStream(props: MessageStreamProps) {
|
||||
const { preferences } = useConfig()
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
|
||||
const sessionCache = getSessionCache(props.instanceId, props.sessionId)
|
||||
let messageItemCache = sessionCache.messageItemCache
|
||||
let toolItemCache = sessionCache.toolItemCache
|
||||
let scrollAnimationFrame: number | null = null
|
||||
let lastKnownScrollTop = 0
|
||||
|
||||
const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}`
|
||||
|
||||
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
|
||||
const messageId = message.id
|
||||
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
|
||||
return `${messageId}:${partId}`
|
||||
}
|
||||
|
||||
function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string {
|
||||
const state = isToolPart(toolPart) ? toolPart.state : undefined
|
||||
const version = typeof toolPart?.version === "number" ? toolPart.version : 0
|
||||
const status = state?.status ?? "unknown"
|
||||
return `${toolPart.id}:${version}:${status}`
|
||||
}
|
||||
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: null,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedSessionInfo = createMemo(() => {
|
||||
const info = sessionInfo()
|
||||
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
|
||||
})
|
||||
|
||||
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
const distance = scrollHeight - (scrollTop + clientHeight)
|
||||
return distance <= offset
|
||||
}
|
||||
|
||||
function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||
return element.scrollTop <= offset
|
||||
}
|
||||
|
||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||
if (!containerRef) return
|
||||
|
||||
const behavior = options.smooth ? "smooth" : "auto"
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||
setAutoScroll(true)
|
||||
updateScrollIndicators(containerRef)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function scrollToTop(options: { smooth?: boolean } = {}) {
|
||||
if (!containerRef) return
|
||||
|
||||
const behavior = options.smooth ? "smooth" : "auto"
|
||||
setAutoScroll(false)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
containerRef.scrollTo({ top: 0, behavior })
|
||||
setShowScrollTopButton(false)
|
||||
updateScrollIndicators(containerRef)
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (!containerRef) return
|
||||
|
||||
if (scrollAnimationFrame !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrame)
|
||||
}
|
||||
|
||||
const isUserScroll = event.isTrusted
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
const currentScrollTop = containerRef.scrollTop
|
||||
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
|
||||
lastKnownScrollTop = currentScrollTop
|
||||
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
|
||||
if (isUserScroll) {
|
||||
if (movingUp && !atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
} else if (!movingUp && atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollIndicators(containerRef)
|
||||
scrollAnimationFrame = null
|
||||
})
|
||||
}
|
||||
|
||||
const messageView = createMemo(() => {
|
||||
const showThinking = preferences().showThinkingBlocks
|
||||
|
||||
const items: DisplayItem[] = []
|
||||
const newMessageCache = new Map<string, MessageCacheEntry>()
|
||||
const newToolCache = new Map<string, ToolCacheEntry>()
|
||||
const tokenSegments: string[] = []
|
||||
|
||||
let lastAssistantIndex = -1
|
||||
for (let i = props.messages.length - 1; i >= 0; i--) {
|
||||
if (props.messages[i].type === "assistant") {
|
||||
lastAssistantIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tokenSegments.push(`count:${props.messages.length}`)
|
||||
tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`)
|
||||
tokenSegments.push(`thinking:${showThinking ? 1 : 0}`)
|
||||
|
||||
for (let index = 0; index < props.messages.length; index++) {
|
||||
const message = props.messages[index]
|
||||
const messageInfo = props.messagesInfo?.get(message.id)
|
||||
|
||||
if (props.revert?.messageID && message.id === props.revert.messageID) {
|
||||
break
|
||||
}
|
||||
|
||||
tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`)
|
||||
|
||||
const baseDisplayParts = message.displayParts
|
||||
const displayParts: MessageDisplayParts =
|
||||
!baseDisplayParts || baseDisplayParts.showThinking !== showThinking
|
||||
? computeDisplayParts(message, showThinking)
|
||||
: (baseDisplayParts as MessageDisplayParts)
|
||||
|
||||
const combinedParts = displayParts.combined
|
||||
const version = message.version ?? 0
|
||||
const isQueued = message.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
|
||||
|
||||
const hasRenderableContent =
|
||||
message.type !== "assistant" ||
|
||||
combinedParts.length > 0 ||
|
||||
Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) ||
|
||||
message.status === "error"
|
||||
|
||||
if (hasRenderableContent) {
|
||||
const cacheEntry = messageItemCache.get(message.id)
|
||||
if (
|
||||
cacheEntry &&
|
||||
cacheEntry.version === version &&
|
||||
cacheEntry.showThinking === showThinking &&
|
||||
cacheEntry.isQueued === isQueued &&
|
||||
cacheEntry.messageInfo === messageInfo
|
||||
) {
|
||||
cacheEntry.displayParts = displayParts
|
||||
cacheEntry.version = version
|
||||
cacheEntry.showThinking = showThinking
|
||||
cacheEntry.isQueued = isQueued
|
||||
cacheEntry.messageInfo = messageInfo
|
||||
cacheEntry.item.message = message
|
||||
cacheEntry.item.combinedParts = combinedParts
|
||||
cacheEntry.item.isQueued = isQueued
|
||||
cacheEntry.item.messageInfo = messageInfo
|
||||
newMessageCache.set(message.id, cacheEntry)
|
||||
items.push(cacheEntry.item)
|
||||
} else {
|
||||
const messageItem: MessageDisplayItem = {
|
||||
type: "message",
|
||||
message,
|
||||
combinedParts,
|
||||
isQueued,
|
||||
messageInfo,
|
||||
}
|
||||
newMessageCache.set(message.id, {
|
||||
message,
|
||||
version,
|
||||
showThinking,
|
||||
isQueued,
|
||||
messageInfo,
|
||||
displayParts,
|
||||
item: messageItem,
|
||||
})
|
||||
items.push(messageItem)
|
||||
}
|
||||
}
|
||||
|
||||
const toolParts = displayParts.tool.filter(isToolPart)
|
||||
for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) {
|
||||
const toolPart = toolParts[toolIndex]
|
||||
const originalIndex = displayParts.tool.indexOf(toolPart)
|
||||
const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}`
|
||||
const messageVersion = typeof message.version === "number" ? message.version : 0
|
||||
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
|
||||
|
||||
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
|
||||
const contentKey = createToolContentKey(toolPart, messageInfo)
|
||||
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
|
||||
const toolEntry = toolItemCache.get(toolKey)
|
||||
|
||||
if (toolEntry && toolEntry.signature === toolSignature) {
|
||||
if (toolEntry.contentKey !== contentKey) {
|
||||
const updatedItem: ToolDisplayItem = {
|
||||
...toolEntry.item,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: message.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
toolEntry.toolPart = toolPart
|
||||
toolEntry.messageInfo = messageInfo
|
||||
toolEntry.signature = toolSignature
|
||||
toolEntry.contentKey = contentKey
|
||||
toolEntry.item = updatedItem
|
||||
console.debug("[ToolCall] update", toolKey, toolPart.state?.status)
|
||||
newToolCache.set(toolKey, toolEntry)
|
||||
items.push(updatedItem)
|
||||
} else {
|
||||
const cachedItem = toolEntry.item
|
||||
cachedItem.toolPart = toolPart
|
||||
cachedItem.messageInfo = messageInfo
|
||||
cachedItem.messageId = message.id
|
||||
cachedItem.messageVersion = messageVersion
|
||||
cachedItem.partVersion = partVersion
|
||||
toolEntry.toolPart = toolPart
|
||||
toolEntry.messageInfo = messageInfo
|
||||
newToolCache.set(toolKey, toolEntry)
|
||||
items.push(cachedItem)
|
||||
}
|
||||
} else {
|
||||
const toolItem: ToolDisplayItem = {
|
||||
type: "tool",
|
||||
key: toolKey,
|
||||
toolPart,
|
||||
messageInfo,
|
||||
messageId: message.id,
|
||||
messageVersion,
|
||||
partVersion,
|
||||
}
|
||||
console.debug("[ToolCall] create", toolKey, toolPart.state?.status)
|
||||
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
|
||||
items.push(toolItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messageItemCache = newMessageCache
|
||||
toolItemCache = newToolCache
|
||||
sessionCache.messageItemCache = messageItemCache
|
||||
sessionCache.toolItemCache = toolItemCache
|
||||
|
||||
tokenSegments.push(`items:${items.length}`)
|
||||
|
||||
if (items.length > 0) {
|
||||
const tail = items[items.length - 1]
|
||||
if (tail.type === "message") {
|
||||
tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`)
|
||||
} else {
|
||||
tokenSegments.push(`tail:${tail.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { items, token: tokenSegments.join("|") }
|
||||
})
|
||||
|
||||
const displayItems = () => messageView().items
|
||||
const changeToken = () => messageView().token
|
||||
|
||||
function updateScrollIndicators(element: HTMLDivElement) {
|
||||
const itemsLength = displayItems().length
|
||||
setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0)
|
||||
setShowScrollTopButton(!isNearTop(element) && itemsLength > 0)
|
||||
persistScrollState()
|
||||
}
|
||||
|
||||
function getActiveScrollKey() {
|
||||
return containerRef?.dataset.scrollKey || scrollStateKey()
|
||||
}
|
||||
|
||||
function persistScrollState() {
|
||||
if (!containerRef) return
|
||||
const key = getActiveScrollKey()
|
||||
messageScrollState.set(key, {
|
||||
scrollTop: containerRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const key = scrollStateKey()
|
||||
if (containerRef) {
|
||||
containerRef.dataset.scrollKey = key
|
||||
}
|
||||
const savedState = messageScrollState.get(key)
|
||||
const shouldAutoScroll = savedState?.autoScroll ?? true
|
||||
|
||||
setAutoScroll(shouldAutoScroll)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!containerRef) return
|
||||
|
||||
if (savedState) {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom({ smooth: false })
|
||||
} else {
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop)
|
||||
updateScrollIndicators(containerRef)
|
||||
}
|
||||
} else {
|
||||
scrollToBottom({ smooth: false })
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (containerRef) {
|
||||
messageScrollState.set(key, {
|
||||
scrollTop: containerRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
if (containerRef.dataset.scrollKey === key) {
|
||||
delete containerRef.dataset.scrollKey
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const shouldScroll = autoScroll()
|
||||
|
||||
if (!token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
|
||||
previousToken = token
|
||||
|
||||
if (!shouldScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (displayItems().length === 0) {
|
||||
setShowScrollBottomButton(false)
|
||||
setShowScrollTopButton(false)
|
||||
setAutoScroll(true)
|
||||
persistScrollState()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollAnimationFrame !== null) {
|
||||
cancelAnimationFrame(scrollAnimationFrame)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class="connection-status">
|
||||
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
|
||||
<span>{formattedSessionInfo()}</span>
|
||||
</div>
|
||||
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</div>
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
|
||||
<Show when={!props.loading && displayItems().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={displayItems()} fallback={null}>
|
||||
{(item) => {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem
|
||||
message={item.message}
|
||||
messageInfo={item.messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={item.isQueued}
|
||||
parts={item.combinedParts}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
/>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const toolPart = item.toolPart
|
||||
|
||||
const toolState = toolPart.state
|
||||
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
|
||||
const taskSessionId =
|
||||
hasToolState && typeof toolState?.metadata?.sessionId === "string"
|
||||
? toolState.metadata.sessionId
|
||||
: ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
|
||||
const handleGoToTaskSession = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!taskLocation) return
|
||||
navigateToTaskSession(taskLocation)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-message" data-key={item.key}>
|
||||
<div class="tool-call-header-label">
|
||||
<div class="tool-call-header-meta">
|
||||
<span class="tool-call-icon">🔧</span>
|
||||
<span>Tool Call</span>
|
||||
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
|
||||
</div>
|
||||
<Show when={taskSessionId}>
|
||||
<button
|
||||
class="tool-call-header-button"
|
||||
type="button"
|
||||
disabled={!taskLocation}
|
||||
onClick={handleGoToTaskSession}
|
||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
||||
>
|
||||
Go to Session
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolPart}
|
||||
toolCallId={item.key}
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToTop({ smooth: true })}
|
||||
aria-label="Scroll to first message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom({ smooth: true })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
441
packages/ui/src/components/message-timeline.tsx
Normal file
441
packages/ui/src/components/message-timeline.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||
import MessagePreview from "./message-preview"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||
|
||||
export interface TimelineSegment {
|
||||
id: string
|
||||
messageId: string
|
||||
type: TimelineSegmentType
|
||||
label: string
|
||||
tooltip: string
|
||||
shortLabel?: string
|
||||
variant?: "auto" | "manual"
|
||||
toolPartIds?: string[]
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
segments: TimelineSegment[]
|
||||
onSegmentClick?: (segment: TimelineSegment) => void
|
||||
activeMessageId?: string | null
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
showToolSegments?: boolean
|
||||
}
|
||||
|
||||
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "You",
|
||||
assistant: "Asst",
|
||||
tool: "Tool",
|
||||
compaction: "Compaction",
|
||||
}
|
||||
|
||||
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||
const MAX_TOOLTIP_LENGTH = 220
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
interface PendingSegment {
|
||||
type: TimelineSegmentType
|
||||
texts: string[]
|
||||
reasoningTexts: string[]
|
||||
toolTitles: string[]
|
||||
toolTypeLabels: string[]
|
||||
toolIcons: string[]
|
||||
toolPartIds: string[]
|
||||
hasPrimaryText: boolean
|
||||
}
|
||||
|
||||
function truncateText(value: string): string {
|
||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function collectReasoningText(part: ClientPart): string {
|
||||
const stringifySegment = (segment: unknown): string => {
|
||||
if (typeof segment === "string") {
|
||||
return segment
|
||||
}
|
||||
if (segment && typeof segment === "object") {
|
||||
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const parts: string[] = []
|
||||
if (typeof obj.text === "string") {
|
||||
parts.push(obj.text)
|
||||
}
|
||||
if (typeof obj.value === "string") {
|
||||
parts.push(obj.value)
|
||||
}
|
||||
if (Array.isArray(obj.content)) {
|
||||
parts.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||
}
|
||||
return parts.filter(Boolean).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if (typeof (part as any)?.text === "string") {
|
||||
return (part as any).text
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return (part as any).content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function collectTextFromPart(part: ClientPart): string {
|
||||
if (!part) return ""
|
||||
if (typeof (part as any).text === "string") {
|
||||
return (part as any).text as string
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return collectReasoningText(part)
|
||||
}
|
||||
if (Array.isArray((part as any)?.content)) {
|
||||
return ((part as any).content as unknown[])
|
||||
.map((entry) => (typeof entry === "string" ? entry : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
if (part.type === "file") {
|
||||
const filename = (part as any)?.filename
|
||||
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function getToolTitle(part: ToolCallPart): string {
|
||||
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
||||
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||
if (title) return title
|
||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||
return part.tool
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
|
||||
function getToolTypeLabel(part: ToolCallPart): string {
|
||||
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||
return part.tool.trim().slice(0, 4)
|
||||
}
|
||||
return TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
}
|
||||
|
||||
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||
const combined = texts
|
||||
.map((text) => text.trim())
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
if (combined.length > 0) {
|
||||
return truncateText(combined)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatToolTooltip(titles: string[]): string {
|
||||
if (titles.length === 0) {
|
||||
return TOOL_FALLBACK_LABEL
|
||||
}
|
||||
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
||||
}
|
||||
|
||||
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
||||
if (!record) return []
|
||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||
if (!orderedParts || orderedParts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: TimelineSegment[] = []
|
||||
let segmentIndex = 0
|
||||
let pending: PendingSegment | null = null
|
||||
const flushPending = () => {
|
||||
if (!pending) return
|
||||
if (pending.type === "assistant" && !pending.hasPrimaryText) {
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
const isToolSegment = pending.type === "tool"
|
||||
const label = isToolSegment
|
||||
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||
: SEGMENT_LABELS[pending.type]
|
||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||
const tooltip = isToolSegment
|
||||
? formatToolTooltip(pending.toolTitles)
|
||||
: formatTextsTooltip(
|
||||
[...pending.texts, ...pending.reasoningTexts],
|
||||
pending.type === "user" ? "User message" : "Assistant response",
|
||||
)
|
||||
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: pending.type,
|
||||
label,
|
||||
tooltip,
|
||||
shortLabel,
|
||||
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||
})
|
||||
segmentIndex += 1
|
||||
pending = null
|
||||
}
|
||||
|
||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||
if (!pending || pending.type !== type) {
|
||||
flushPending()
|
||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||
}
|
||||
return pending!
|
||||
}
|
||||
|
||||
|
||||
const defaultContentType: TimelineSegmentType = record.role === "user" ? "user" : "assistant"
|
||||
|
||||
for (const part of orderedParts) {
|
||||
if (!part || typeof part !== "object") continue
|
||||
|
||||
if (part.type === "tool") {
|
||||
const target = ensureSegment("tool")
|
||||
const toolPart = part as ToolCallPart
|
||||
target.toolTitles.push(getToolTitle(toolPart))
|
||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
||||
target.toolPartIds.push(toolPart.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
const text = collectReasoningText(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.reasoningTexts.push(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushPending()
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: "compaction",
|
||||
label: SEGMENT_LABELS.compaction,
|
||||
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = collectTextFromPart(part)
|
||||
if (text.trim().length === 0) continue
|
||||
const target = ensureSegment(defaultContentType)
|
||||
if (target) {
|
||||
target.texts.push(text)
|
||||
target.hasPrimaryText = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flushPending()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||
const [tooltipCoords, setTooltipCoords] = createSignal<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||
const [hoverAnchorRect, setHoverAnchorRect] = createSignal<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||
let hoverTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
buttonRefs.set(segmentId, element)
|
||||
} else {
|
||||
buttonRefs.delete(segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(hoverTimer)
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
const target = event.currentTarget as HTMLButtonElement
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setHoverAnchorRect({ top: rect.top, left: rect.left, width: rect.width, height: rect.height })
|
||||
setHoveredSegment(segment)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoverTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const anchor = hoverAnchorRect()
|
||||
const segment = hoveredSegment()
|
||||
if (!anchor || !segment) return
|
||||
const { width, height } = tooltipSize()
|
||||
const verticalGap = 16
|
||||
const horizontalGap = 16
|
||||
const preferredTop = anchor.top + anchor.height / 2 - height / 2
|
||||
const maxTop = window.innerHeight - height - verticalGap
|
||||
const clampedTop = Math.min(maxTop, Math.max(verticalGap, preferredTop))
|
||||
const preferredLeft = anchor.left - width - horizontalGap
|
||||
const clampedLeft = Math.max(horizontalGap, preferredLeft)
|
||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||
})
|
||||
|
||||
onCleanup(() => clearHoverTimer())
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
|
||||
if (!activeId) return
|
||||
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
||||
if (!targetSegment) return
|
||||
const element = buttonRefs.get(targetSegment.id)
|
||||
if (!element) return
|
||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}, 120) : null
|
||||
onCleanup(() => {
|
||||
if (timer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const element = tooltipElement()
|
||||
if (!element || typeof window === "undefined") return
|
||||
const updateSize = () => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
setTooltipSize({ width: rect.width, height: rect.height })
|
||||
}
|
||||
updateSize()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
const observer = new ResizeObserver(() => updateSize())
|
||||
observer.observe(element)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const previewData = createMemo(() => {
|
||||
|
||||
const segment = hoveredSegment()
|
||||
if (!segment) return null
|
||||
const record = store().getMessage(segment.messageId)
|
||||
if (!record) return null
|
||||
return { messageId: segment.messageId }
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
|
||||
const hasActivePermission = () => {
|
||||
if (segment.type !== "tool") return false
|
||||
const partIds = segment.toolPartIds ?? []
|
||||
if (partIds.length === 0) return false
|
||||
for (const partId of partIds) {
|
||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||
if (permissionState?.active) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
|
||||
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
if (hasActivePermission()) {
|
||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "compaction") {
|
||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={previewData()}>
|
||||
{(data) => {
|
||||
onCleanup(() => setTooltipElement(null))
|
||||
return (
|
||||
<div
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageTimeline
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user