Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158f6e25cf | ||
|
|
562c4b2637 | ||
|
|
51fd5d87f7 | ||
|
|
28fb56bfa1 | ||
|
|
c1052b36dc | ||
|
|
c62c9b1c78 | ||
|
|
feccbd13bd | ||
|
|
5b1e21345f | ||
|
|
33939f4096 | ||
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 | ||
|
|
0c9284e57e | ||
|
|
0766185ff6 | ||
|
|
effb30d98e | ||
|
|
4da69b5a20 | ||
|
|
3d3337c7b8 | ||
|
|
f0b43dbc68 | ||
|
|
b0eb9aec64 | ||
|
|
8c48455ae5 | ||
|
|
292f695395 | ||
|
|
4ea710c735 | ||
|
|
f5d4cb6917 | ||
|
|
1e53e06424 | ||
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd | ||
|
|
dfd397803f | ||
|
|
267f1592c4 | ||
|
|
668ac7fa88 | ||
|
|
43a476e967 | ||
|
|
adbfab5c25 | ||
|
|
02f1284f7f | ||
|
|
a014ce555a | ||
|
|
db3c13c463 | ||
|
|
7c0bf382ba | ||
|
|
6e9c5a88b4 | ||
|
|
0bf22a323f | ||
|
|
cc997576cf | ||
|
|
05f193df7b | ||
|
|
c9b5bb1b7a | ||
|
|
ba1013cd35 | ||
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc | ||
|
|
37da426ab4 | ||
|
|
591f55bef9 | ||
|
|
aabaadbe1d | ||
|
|
3ab14e8de6 | ||
|
|
40634138bc | ||
|
|
b17087b610 | ||
|
|
71f58e7c5f | ||
|
|
927e4e1281 | ||
|
|
2e56a5e9f4 | ||
|
|
296d07a0d6 | ||
|
|
0d8a844af8 | ||
|
|
bf9cef4cd5 | ||
|
|
9dde33aba7 | ||
|
|
0fefff3b0a | ||
|
|
1122c19648 | ||
|
|
f06359a1fc | ||
|
|
72f420b6f6 | ||
|
|
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 | ||
|
|
b4663fb250 |
148
.github/workflows/build-and-upload.yml
vendored
148
.github/workflows/build-and-upload.yml
vendored
@@ -4,21 +4,33 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: true
|
required: false
|
||||||
|
default: ""
|
||||||
type: string
|
type: string
|
||||||
tag:
|
tag:
|
||||||
description: "Git tag to upload assets to"
|
description: "Git tag to upload assets to (release builds)"
|
||||||
required: true
|
required: false
|
||||||
|
default: ""
|
||||||
type: string
|
type: string
|
||||||
release_name:
|
release_name:
|
||||||
description: "Release name (unused here, for context)"
|
description: "Release name (unused here, for context)"
|
||||||
required: true
|
required: false
|
||||||
|
default: ""
|
||||||
type: string
|
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:
|
# Permissions are intentionally omitted here so callers can choose
|
||||||
id-token: write
|
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
@@ -41,10 +53,11 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||||
@@ -53,6 +66,7 @@ jobs:
|
|||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -79,11 +93,12 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||||
@@ -92,6 +107,7 @@ jobs:
|
|||||||
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||||
@@ -116,10 +132,11 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
@@ -128,6 +145,7 @@ jobs:
|
|||||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -157,18 +175,38 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
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)
|
- name: Build macOS bundle (Tauri)
|
||||||
run: npm run build --workspace @codenomad/tauri-app
|
working-directory: packages/tauri-app
|
||||||
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -180,6 +218,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -209,18 +248,38 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
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)
|
- name: Build macOS bundle (Tauri, arm64)
|
||||||
run: npm run build --workspace @codenomad/tauri-app
|
working-directory: packages/tauri-app
|
||||||
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -232,6 +291,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -261,19 +321,41 @@ jobs:
|
|||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
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)
|
- name: Build Windows bundle (Tauri)
|
||||||
run: npm run build --workspace @codenomad/tauri-app
|
shell: bash
|
||||||
|
working-directory: packages/tauri-app
|
||||||
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
|
if: ${{ inputs.upload }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -287,6 +369,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
if (Test-Path "packages/tauri-app/release-tauri") {
|
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||||
@@ -329,18 +412,38 @@ jobs:
|
|||||||
librsvg2-dev
|
librsvg2-dev
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
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)
|
- name: Build Linux bundle (Tauri)
|
||||||
run: npm run build --workspace @codenomad/tauri-app
|
working-directory: packages/tauri-app
|
||||||
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
|
if: ${{ inputs.upload }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -367,6 +470,7 @@ jobs:
|
|||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -429,7 +533,7 @@ jobs:
|
|||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||||
@@ -497,10 +601,11 @@ jobs:
|
|||||||
sudo gem install --no-document fpm
|
sudo gem install --no-document fpm
|
||||||
|
|
||||||
- name: Set workspace versions
|
- 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
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Install project dependencies
|
- name: Install project dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces --include=optional
|
||||||
|
|
||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
@@ -509,6 +614,7 @@ jobs:
|
|||||||
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
- name: Upload RPM release assets
|
- name: Upload RPM release assets
|
||||||
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
|
|||||||
16
.github/workflows/dev-release.yml
vendored
16
.github/workflows/dev-release.yml
vendored
@@ -1,16 +1,18 @@
|
|||||||
name: Dev Release
|
name: Dev CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
contents: read
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dev-release:
|
dev-ci:
|
||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
version_suffix: -dev
|
upload: false
|
||||||
dist_tag: dev
|
set_versions: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
47
.github/workflows/release-ui.yml
vendored
Normal file
47
.github/workflows/release-ui.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Release UI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call: {}
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-ui:
|
||||||
|
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||||
|
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
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 --include=optional
|
||||||
|
|
||||||
|
- name: Ensure rollup native binary
|
||||||
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
|
- name: Install Cloudflare worker deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
|
||||||
|
- name: Build UI
|
||||||
|
run: npm run build --workspace @codenomad/ui
|
||||||
|
|
||||||
|
- name: Publish UI zip + update manifest
|
||||||
|
working-directory: packages/cloudflare
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
|
||||||
|
run: npm run release:ui
|
||||||
7
.github/workflows/reusable-release.yml
vendored
7
.github/workflows/reusable-release.yml
vendored
@@ -69,6 +69,13 @@ jobs:
|
|||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
release-ui:
|
||||||
|
needs: prepare-release
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -7,4 +7,9 @@ release/
|
|||||||
.electron-vite/
|
.electron-vite/
|
||||||
out/
|
out/
|
||||||
.dir-locals.el
|
.dir-locals.el
|
||||||
.opencode/bashOutputs/
|
.opencode/bashOutputs/
|
||||||
|
|
||||||
|
# Local runtime artifacts
|
||||||
|
.codenomad/
|
||||||
|
.tmp/
|
||||||
|
packages/cloudflare/.wrangler/
|
||||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Creates release notes
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
|
||||||
|
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||||
|
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||||
23
README.md
23
README.md
@@ -76,6 +76,29 @@ 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.
|
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
|
## Architecture & Development
|
||||||
|
|
||||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||||
|
|||||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/server",
|
||||||
|
"packages/ui",
|
||||||
|
"packages/electron-app",
|
||||||
|
"packages/tauri-app"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1092,29 +1095,10 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/plugin": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
|
||||||
"zod": "4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@opencode-ai/plugin/node_modules/zod": {
|
|
||||||
"version": "4.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
|
||||||
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
||||||
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==",
|
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -1648,7 +1632,6 @@
|
|||||||
"version": "2.10.3",
|
"version": "2.10.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -2287,7 +2270,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/buffer-crc32": {
|
"node_modules/buffer-crc32": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
@@ -3690,7 +3672,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pend": "~1.2.0"
|
"pend": "~1.2.0"
|
||||||
@@ -5268,10 +5249,6 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"regex-recursion": "^6.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/opencode-config": {
|
|
||||||
"resolved": "packages/opencode-config",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/p-cancelable": {
|
"node_modules/p-cancelable": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5372,7 +5349,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -7344,7 +7320,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/yauzl": {
|
"node_modules/yauzl": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "~0.2.3",
|
"buffer-crc32": "~0.2.3",
|
||||||
@@ -7409,7 +7384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -7434,14 +7409,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"packages/opencode-config": {
|
"packages/opencode-config": {
|
||||||
|
"name": "@codenomad/opencode-config",
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.1"
|
"@opencode-ai/plugin": "1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -7451,12 +7428,14 @@
|
|||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
@@ -7476,18 +7455,18 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/server",
|
||||||
|
"packages/ui",
|
||||||
|
"packages/electron-app",
|
||||||
|
"packages/tauri-app"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
1
packages/cloudflare/.gitignore
vendored
Normal file
1
packages/cloudflare/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
1515
packages/cloudflare/package-lock.json
generated
Normal file
1515
packages/cloudflare/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
packages/cloudflare/package.json
Normal file
14
packages/cloudflare/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@codenomad/ui-host-worker",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:manifest": "node ./scripts/build-manifest.mjs",
|
||||||
|
"release:ui": "node ./scripts/release-ui.mjs",
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/cloudflare/release-config.json
Normal file
4
packages/cloudflare/release-config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"minServerVersion": "0.9.2",
|
||||||
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
|
}
|
||||||
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
83
packages/cloudflare/scripts/build-manifest.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const releaseConfigPath = path.join(root, "release-config.json")
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
|
||||||
|
|
||||||
|
const distDir = path.join(root, "dist")
|
||||||
|
const manifestPath = path.join(distDir, "version.json")
|
||||||
|
|
||||||
|
const args = new Set(process.argv.slice(2))
|
||||||
|
|
||||||
|
function getArgValue(flag) {
|
||||||
|
const idx = process.argv.indexOf(flag)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return process.argv[idx + 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipPath = getArgValue("--zip")
|
||||||
|
|
||||||
|
if (!zipPath) {
|
||||||
|
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
|
||||||
|
if (!fs.existsSync(resolvedZipPath)) {
|
||||||
|
console.error(`Zip not found: ${resolvedZipPath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
|
||||||
|
|
||||||
|
const bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
const serverVersion = serverPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion || !serverVersion) {
|
||||||
|
console.error("Missing version fields in package.json")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
|
||||||
|
|
||||||
|
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
minServerVersion: releaseConfig.minServerVersion,
|
||||||
|
latestUIVersion: uiVersion,
|
||||||
|
uiPackageURL,
|
||||||
|
sha256,
|
||||||
|
latestServerVersion: serverVersion,
|
||||||
|
latestServerUrl: releaseConfig.latestServerUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true })
|
||||||
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||||
|
|
||||||
|
const headersPath = path.join(distDir, "_headers")
|
||||||
|
fs.writeFileSync(
|
||||||
|
headersPath,
|
||||||
|
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Wrote ${manifestPath}`)
|
||||||
|
console.log(`Wrote ${headersPath}`)
|
||||||
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
81
packages/cloudflare/scripts/release-ui.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { execFileSync } from "child_process"
|
||||||
|
import fs from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const repoRoot = path.resolve(root, "..", "..")
|
||||||
|
|
||||||
|
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
|
||||||
|
|
||||||
|
if (!r2Bucket) {
|
||||||
|
console.error("Missing env var: CODENOMAD_R2_BUCKET")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
|
||||||
|
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
|
||||||
|
const uiVersion = uiPackageJson.version
|
||||||
|
|
||||||
|
if (!uiVersion) {
|
||||||
|
console.error("Missing packages/ui/package.json version")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
|
||||||
|
if (!fs.existsSync(uiBuildDir)) {
|
||||||
|
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
|
||||||
|
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
|
||||||
|
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
|
||||||
|
|
||||||
|
// Upload to R2.
|
||||||
|
const objectKey = `ui/ui-${uiVersion}.zip`
|
||||||
|
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
|
||||||
|
|
||||||
|
execFileSync(
|
||||||
|
"npx",
|
||||||
|
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
|
||||||
|
{ cwd: root, stdio: "inherit" },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate version.json into packages/cloudflare/dist
|
||||||
|
console.log("[release-ui] Generating version.json")
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
|
||||||
|
{
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CODENOMAD_R2_BUCKET: r2Bucket,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log("[release-ui] Deploying worker")
|
||||||
|
execFileSync("npx", ["wrangler", "deploy"], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("[release-ui] Done")
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
9
packages/cloudflare/src/index.ts
Normal file
9
packages/cloudflare/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Env {
|
||||||
|
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
return env.ASSETS.fetch(request)
|
||||||
|
},
|
||||||
|
}
|
||||||
14
packages/cloudflare/wrangler.toml
Normal file
14
packages/cloudflare/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "codenomad-ui-host"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2026-01-22"
|
||||||
|
|
||||||
|
# Custom domain for the manifest host.
|
||||||
|
# Note: Custom domains apply to all paths on the hostname.
|
||||||
|
[[routes]]
|
||||||
|
pattern = "ui.codenomad.neuralnomads.ai"
|
||||||
|
custom_domain = true
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./dist"
|
||||||
|
binding = "ASSETS"
|
||||||
|
not_found_handling = "404-page"
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||||
|
import http from "node:http"
|
||||||
|
import https from "node:https"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { dirname, join } from "path"
|
import { dirname, join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
|
|||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let currentCliUrl: string | null = null
|
let currentCliUrl: string | null = null
|
||||||
let pendingCliUrl: string | null = null
|
let pendingCliUrl: string | null = null
|
||||||
|
let pendingBootstrapToken: string | null = null
|
||||||
let showingLoadingScreen = false
|
let showingLoadingScreen = false
|
||||||
let preloadingView: BrowserView | null = null
|
let preloadingView: BrowserView | null = null
|
||||||
|
|
||||||
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
|
|||||||
loadLoadingScreen(mainWindow)
|
loadLoadingScreen(mainWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBootstrapTokenUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCliPreload(url: string) {
|
function startCliPreload(url: string) {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
pendingCliUrl = url
|
pendingCliUrl = url
|
||||||
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
|
|||||||
showLoadingScreen(true)
|
showLoadingScreen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
|
||||||
|
// consuming the token in the hidden view and then failing in the main window.
|
||||||
|
if (isBootstrapTokenUrl(url)) {
|
||||||
|
finalizeCliSwap(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const view = new BrowserView({
|
const view = new BrowserView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||||
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
|
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
const first = raw.split(";")[0] ?? ""
|
||||||
|
const index = first.indexOf("=")
|
||||||
|
if (index < 0) return null
|
||||||
|
|
||||||
|
const key = first.slice(0, index).trim()
|
||||||
|
const value = first.slice(index + 1).trim()
|
||||||
|
if (key !== name || !value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
|
const transport = target.protocol === "https:" ? https : http
|
||||||
|
|
||||||
|
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
|
||||||
|
const req = transport.request(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume()
|
||||||
|
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
req.on("error", reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.statusCode !== 200) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||||
|
if (!sessionId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.defaultSession.cookies.set({
|
||||||
|
url: baseUrl,
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
value: sessionId,
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function startCli() {
|
async function startCli() {
|
||||||
try {
|
try {
|
||||||
@@ -323,11 +411,53 @@ async function startCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function maybeExchangeAndNavigate(baseUrl: string) {
|
||||||
|
if (bootstrapExchangeInFlight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = pendingBootstrapToken
|
||||||
|
if (!token) {
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapExchangeInFlight = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await exchangeBootstrapToken(baseUrl, token)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startCliPreload(baseUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[cli] bootstrap token exchange failed:", error)
|
||||||
|
pendingBootstrapToken = null
|
||||||
|
startCliPreload(`${baseUrl}/login`)
|
||||||
|
} finally {
|
||||||
|
bootstrapExchangeInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cliManager.on("bootstrapToken", (token) => {
|
||||||
|
pendingBootstrapToken = token
|
||||||
|
|
||||||
|
const status = cliManager.getStatus()
|
||||||
|
if (status.url) {
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cliManager.on("ready", (status) => {
|
cliManager.on("ready", (status) => {
|
||||||
if (!status.url) {
|
if (!status.url) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startCliPreload(status.url)
|
|
||||||
|
void maybeExchangeAndNavigate(status.url)
|
||||||
})
|
})
|
||||||
|
|
||||||
cliManager.on("status", (status) => {
|
cliManager.on("status", (status) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
|
|||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
export declare interface CliProcessManager {
|
export declare interface CliProcessManager {
|
||||||
on(event: "status", listener: (status: CliStatus) => void): this
|
on(event: "status", listener: (status: CliStatus) => void): this
|
||||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||||
|
on(event: "bootstrapToken", listener: (token: string) => void): this
|
||||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||||
on(event: "error", listener: (error: Error) => void): this
|
on(event: "error", listener: (error: Error) => void): this
|
||||||
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
|
private bootstrapToken: string | null = null
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
|
this.bootstrapToken = null
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
@@ -173,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const killTimeout = setTimeout(() => {
|
const killTimeout = setTimeout(() => {
|
||||||
|
console.warn(
|
||||||
|
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||||
|
)
|
||||||
child.kill("SIGKILL")
|
child.kill("SIGKILL")
|
||||||
}, 4000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
child.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
@@ -227,11 +234,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue
|
const trimmed = line.trim()
|
||||||
console.info(`[cli][${stream}] ${line}`)
|
if (!trimmed) continue
|
||||||
this.emit("log", { stream, message: line })
|
|
||||||
|
|
||||||
const port = this.extractPort(line)
|
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
||||||
|
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
||||||
|
if (token && !this.bootstrapToken) {
|
||||||
|
this.bootstrapToken = token
|
||||||
|
this.emit("bootstrapToken", token)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[cli][${stream}] ${trimmed}`)
|
||||||
|
this.emit("log", { stream, message: trimmed })
|
||||||
|
|
||||||
|
const port = this.extractPort(trimmed)
|
||||||
if (port && this.status.state === "starting") {
|
if (port && this.status.state === "starting") {
|
||||||
const url = `http://127.0.0.1:${port}`
|
const url = `http://127.0.0.1:${port}`
|
||||||
console.info(`[cli] ready on ${url}`)
|
console.info(`[cli] ready on ${url}`)
|
||||||
@@ -271,7 +289,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--port", "0"]
|
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||||
@@ -361,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { join } from "path"
|
import path, { join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
@@ -55,12 +55,22 @@ const platforms = {
|
|||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
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 = {
|
const spawnOptions = {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
...options,
|
...options,
|
||||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
env,
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(command, args, spawnOptions)
|
const child = spawn(command, args, spawnOptions)
|
||||||
|
|||||||
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.36"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { tool } from "@opencode-ai/plugin/tool"
|
import { tool } from "@opencode-ai/plugin/tool"
|
||||||
|
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
|
||||||
|
|
||||||
type BackgroundProcess = {
|
type BackgroundProcess = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,11 +13,6 @@ type BackgroundProcess = {
|
|||||||
outputSizeBytes?: number
|
outputSizeBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type CodeNomadConfig = {
|
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackgroundProcessOptions = {
|
type BackgroundProcessOptions = {
|
||||||
baseDir: string
|
baseDir: string
|
||||||
}
|
}
|
||||||
@@ -27,30 +23,10 @@ type ParsedCommand = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||||
|
const requester = createCodeNomadRequester(config)
|
||||||
|
|
||||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
return requester.requestJson<T>(`/background-processes${path}`, init)
|
||||||
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 {
|
return {
|
||||||
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
|
|||||||
|
|
||||||
if (char === "|" || char === "&" || char === ";") {
|
if (char === "|" || char === "&" || char === ";") {
|
||||||
flush()
|
flush()
|
||||||
const next = input[index + 1]
|
tokens.push(char)
|
||||||
if ((char === "|" || char === "&") && next === char) {
|
|
||||||
tokens.push(char + next)
|
|
||||||
index += 1
|
|
||||||
} else {
|
|
||||||
tokens.push(char)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
|
|||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSeparator(token: string) {
|
function isSeparator(token: string): boolean {
|
||||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
return token === "|" || token === "&" || token === ";"
|
||||||
}
|
}
|
||||||
|
|
||||||
function unquote(value: string) {
|
function unquote(token: string): string {
|
||||||
if (value.length >= 2) {
|
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
|
||||||
const first = value[0]
|
return token.slice(1, -1)
|
||||||
const last = value[value.length - 1]
|
|
||||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
||||||
return value.slice(1, -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return value
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinBase(baseDir: string, target: string) {
|
function isWithinBase(base: string, candidate: string): boolean {
|
||||||
const relative = path.relative(baseDir, target)
|
const relative = path.relative(base, candidate)
|
||||||
if (!relative) return true
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,41 @@
|
|||||||
export type PluginEvent = {
|
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
type: string
|
|
||||||
properties?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CodeNomadConfig = {
|
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
|
||||||
instanceId: string
|
|
||||||
baseUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
|
||||||
return {
|
|
||||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
|
||||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||||
return {
|
const requester = createCodeNomadRequester(config)
|
||||||
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 {
|
return {
|
||||||
const value = process.env[key]
|
postEvent: (event: PluginEvent) =>
|
||||||
if (!value || !value.trim()) {
|
requester.requestVoid("/event", {
|
||||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
method: "POST",
|
||||||
|
body: JSON.stringify(event),
|
||||||
|
}),
|
||||||
|
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
async function startPluginEvents(
|
||||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
const response = await fetch(url, {
|
onEvent: (event: PluginEvent) => void,
|
||||||
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.
|
// Fail plugin startup if we cannot establish the initial connection.
|
||||||
const initialBody = await connectWithRetries(url, 3)
|
const initialBody = await connectWithRetries(requester, 3)
|
||||||
|
|
||||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||||
void consumeWithReconnect(url, onEvent, initialBody)
|
void consumeWithReconnect(requester, onEvent, initialBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
|
||||||
let lastError: unknown
|
let lastError: unknown
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
return await requester.requestSseBody("/events")
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
|
||||||
}
|
|
||||||
return response.body
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error
|
||||||
await delay(500 * attempt)
|
await delay(500 * attempt)
|
||||||
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
const url = requester.buildUrl("/events")
|
||||||
|
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function consumeWithReconnect(
|
async function consumeWithReconnect(
|
||||||
url: string,
|
requester: ReturnType<typeof createCodeNomadRequester>,
|
||||||
onEvent: (event: PluginEvent) => void,
|
onEvent: (event: PluginEvent) => void,
|
||||||
initialBody: ReadableStream<Uint8Array>,
|
initialBody: ReadableStream<Uint8Array>,
|
||||||
) {
|
) {
|
||||||
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
body = await connectWithRetries(url, 3)
|
body = await connectWithRetries(requester, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
await consumeSseBody(body, onEvent)
|
await consumeSseBody(body, onEvent)
|
||||||
|
|||||||
124
packages/opencode-config/plugin/lib/request.ts
Normal file
124
packages/opencode-config/plugin/lib/request.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 createCodeNomadRequester(config: CodeNomadConfig) {
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||||
|
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||||
|
const authorization = buildInstanceAuthorizationHeader()
|
||||||
|
|
||||||
|
const buildUrl = (path: string) => {
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||||
|
return `${pluginBase}${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
||||||
|
const output: Record<string, string> = normalizeHeaders(headers)
|
||||||
|
output.Authorization = authorization
|
||||||
|
if (hasBody) {
|
||||||
|
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = buildUrl(path)
|
||||||
|
const hasBody = init?.body !== undefined
|
||||||
|
const headers = buildHeaders(init?.headers, hasBody)
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
||||||
|
const response = await fetchWithAuth(path, init)
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text().catch(() => "")
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
||||||
|
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`SSE unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
return response.body as ReadableStream<Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildUrl,
|
||||||
|
fetch: fetchWithAuth,
|
||||||
|
requestJson,
|
||||||
|
requestVoid,
|
||||||
|
requestSseBody,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 buildInstanceAuthorizationHeader(): string {
|
||||||
|
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
||||||
|
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
774
packages/server/package-lock.json
generated
774
packages/server/package-lock.json
generated
@@ -1,20 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"codenomad": "dist/bin.js"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
@@ -475,6 +485,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/ajv-compiler": {
|
"node_modules/@fastify/ajv-compiler": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
|
||||||
@@ -486,6 +505,15 @@
|
|||||||
"fast-uri": "^2.0.0"
|
"fast-uri": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/busboy": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/cors": {
|
"node_modules/@fastify/cors": {
|
||||||
"version": "8.5.0",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
|
||||||
@@ -520,6 +548,77 @@
|
|||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/reply-from": {
|
||||||
|
"version": "9.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
|
||||||
|
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/error": "^3.0.0",
|
||||||
|
"end-of-stream": "^1.4.4",
|
||||||
|
"fast-content-type-parse": "^1.1.0",
|
||||||
|
"fast-querystring": "^1.0.0",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"toad-cache": "^3.7.0",
|
||||||
|
"undici": "^5.19.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/reply-from/node_modules/undici": {
|
||||||
|
"version": "5.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||||
|
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/busboy": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/send": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.1",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"fast-decode-uri-component": "^1.0.1",
|
||||||
|
"http-errors": "2.0.0",
|
||||||
|
"mime": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/static": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/accept-negotiator": "^1.0.0",
|
||||||
|
"@fastify/send": "^2.0.0",
|
||||||
|
"content-disposition": "^0.5.3",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"fastq": "^1.17.0",
|
||||||
|
"glob": "^10.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -548,12 +647,31 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
@@ -593,6 +711,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/yauzl": {
|
||||||
|
"version": "2.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||||
|
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -674,6 +802,30 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
@@ -700,6 +852,48 @@
|
|||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "0.2.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
@@ -709,6 +903,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
@@ -725,6 +931,48 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "7.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
|
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "src/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
@@ -735,6 +983,27 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -777,6 +1046,12 @@
|
|||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-content-type-parse": {
|
"node_modules/fast-content-type-parse": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
|
||||||
@@ -891,6 +1166,15 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fd-slicer": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pend": "~1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
|
||||||
@@ -905,6 +1189,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -929,6 +1229,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fuzzysort": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
@@ -942,6 +1248,48 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"inherits": "2.0.4",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "2.0.1",
|
||||||
|
"toidentifier": "1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -951,6 +1299,36 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-ref-resolver": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
|
||||||
@@ -977,6 +1355,12 @@
|
|||||||
"set-cookie-parser": "^2.4.1"
|
"set-cookie-parser": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/make-error": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
@@ -984,6 +1368,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mnemonist": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.39.6",
|
"version": "0.39.6",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
|
||||||
@@ -1008,6 +1428,52 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pend": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "9.14.0",
|
"version": "9.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
@@ -1139,6 +1605,26 @@
|
|||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
|
||||||
@@ -1181,6 +1667,45 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
@@ -1199,6 +1724,111 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
@@ -1217,6 +1847,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-node": {
|
"node_modules/ts-node": {
|
||||||
"version": "10.9.2",
|
"version": "10.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
@@ -1296,6 +1935,15 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||||
|
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -1310,6 +1958,128 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yauzl": {
|
||||||
|
"version": "2.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
|
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-crc32": "~0.2.3",
|
||||||
|
"fd-slicer": "~1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yn": {
|
"node_modules/yn": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
"codenomad": "dist/bin.js"
|
"codenomad": "dist/bin.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||||
"build:ui": "npm run build --prefix ../ui",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"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",
|
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -32,9 +32,11 @@
|
|||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/yauzl": "^2.10.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|||||||
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
22
packages/server/scripts/copy-auth-pages.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
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, "src/server/routes/auth-pages")
|
||||||
|
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
|
||||||
|
|
||||||
|
if (!existsSync(sourceDir)) {
|
||||||
|
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
|
mkdirSync(targetDir, { recursive: true })
|
||||||
|
cpSync(sourceDir, targetDir, { recursive: true })
|
||||||
|
|
||||||
|
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
@@ -10,7 +10,9 @@ const cliRoot = path.resolve(__dirname, "..")
|
|||||||
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
||||||
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
||||||
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
|
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
|
||||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
|
||||||
|
const npmExecPath = process.env.npm_execpath
|
||||||
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
if (!existsSync(sourceDir)) {
|
if (!existsSync(sourceDir)) {
|
||||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||||
@@ -19,27 +21,39 @@ if (!existsSync(sourceDir)) {
|
|||||||
|
|
||||||
if (!existsSync(nodeModulesDir)) {
|
if (!existsSync(nodeModulesDir)) {
|
||||||
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
|
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
|
||||||
const result = spawnSync(
|
|
||||||
npmCmd,
|
const npmArgs = [
|
||||||
[
|
"install",
|
||||||
"install",
|
"--prefix",
|
||||||
"--prefix",
|
sourceDir,
|
||||||
sourceDir,
|
"--omit=dev",
|
||||||
"--omit=dev",
|
"--ignore-scripts",
|
||||||
"--ignore-scripts",
|
"--fund=false",
|
||||||
"--fund=false",
|
"--audit=false",
|
||||||
"--audit=false",
|
"--package-lock=false",
|
||||||
"--package-lock=false",
|
"--workspaces=false",
|
||||||
"--workspaces=false",
|
]
|
||||||
],
|
|
||||||
{ cwd: sourceDir, stdio: "inherit", env: { ...process.env, npm_config_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.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")
|
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
|
||||||
process.exit(result.status ?? 1)
|
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 })
|
rmSync(targetDir, { recursive: true, force: true })
|
||||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||||
cpSync(sourceDir, targetDir, { recursive: true })
|
cpSync(sourceDir, targetDir, { recursive: true })
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
|
|||||||
metadata: FileSystemListingMetadata
|
metadata: FileSystemListingMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileSystemCreateFolderRequest {
|
||||||
|
/**
|
||||||
|
* Path identifier for the currently browsed directory.
|
||||||
|
* Matches the `path` parameter used for `/api/filesystem`.
|
||||||
|
*/
|
||||||
|
parentPath?: string
|
||||||
|
/** Single folder name (no separators). */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileSystemCreateFolderResponse {
|
||||||
|
/**
|
||||||
|
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
|
||||||
|
* Relative for restricted listings, absolute for unrestricted.
|
||||||
|
*/
|
||||||
|
path: string
|
||||||
|
/** Absolute folder path on the server host. */
|
||||||
|
absolutePath: string
|
||||||
|
}
|
||||||
|
|
||||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||||
|
|
||||||
export interface WorkspaceFileResponse {
|
export interface WorkspaceFileResponse {
|
||||||
@@ -167,7 +187,6 @@ export type WorkspaceEventType =
|
|||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
| "app.releaseAvailable"
|
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
@@ -180,7 +199,6 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
|
||||||
|
|
||||||
export interface NetworkAddress {
|
export interface NetworkAddress {
|
||||||
ip: string
|
ip: string
|
||||||
@@ -198,6 +216,19 @@ export interface LatestReleaseInfo {
|
|||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UiMeta {
|
||||||
|
version?: string
|
||||||
|
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportMeta {
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||||
httpBaseUrl: string
|
httpBaseUrl: string
|
||||||
@@ -215,8 +246,9 @@ export interface ServerMeta {
|
|||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
/** Reachable addresses for this server, external first. */
|
/** Reachable addresses for this server, external first. */
|
||||||
addresses: NetworkAddress[]
|
addresses: NetworkAddress[]
|
||||||
/** Optional metadata about the most recent public release. */
|
serverVersion?: string
|
||||||
latestRelease?: LatestReleaseInfo
|
ui?: UiMeta
|
||||||
|
support?: SupportMeta
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|||||||
175
packages/server/src/auth/auth-store.ts
Normal file
175
packages/server/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
|
||||||
|
|
||||||
|
export interface AuthFile {
|
||||||
|
version: 1
|
||||||
|
username: string
|
||||||
|
password: PasswordHashRecord
|
||||||
|
userProvided: boolean
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
username: string
|
||||||
|
passwordUserProvided: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthStore {
|
||||||
|
private cachedFile: AuthFile | null = null
|
||||||
|
private overrideAuth: AuthFile | null = null
|
||||||
|
private bootstrapUsername: string | null = null
|
||||||
|
|
||||||
|
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
|
||||||
|
|
||||||
|
getAuthFilePath() {
|
||||||
|
return this.authFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): AuthFile | null {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
return this.overrideAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cachedFile) {
|
||||||
|
return this.cachedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.authFilePath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(this.authFilePath, "utf-8")
|
||||||
|
const parsed = JSON.parse(raw) as AuthFile
|
||||||
|
if (!parsed || parsed.version !== 1) {
|
||||||
|
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
this.cachedFile = parsed
|
||||||
|
return parsed
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureInitialized(params: {
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
allowBootstrapWithoutPassword: boolean
|
||||||
|
}): void {
|
||||||
|
const password = params.password?.trim()
|
||||||
|
if (password) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const runtime: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: params.username,
|
||||||
|
password: hashPassword(password),
|
||||||
|
userProvided: true,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
this.overrideAuth = runtime
|
||||||
|
this.cachedFile = null
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.load()
|
||||||
|
if (existing) {
|
||||||
|
if (existing.username !== params.username) {
|
||||||
|
// Keep existing username unless explicitly overridden later.
|
||||||
|
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
|
||||||
|
}
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.allowBootstrapWithoutPassword) {
|
||||||
|
this.bootstrapUsername = params.username
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCredentials(username: string, password: string): boolean {
|
||||||
|
const auth = this.load()
|
||||||
|
if (!auth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== auth.username) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyPassword(password, auth.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
|
||||||
|
if (this.overrideAuth) {
|
||||||
|
throw new Error(
|
||||||
|
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.load()
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
if (!this.bootstrapUsername) {
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: AuthFile = {
|
||||||
|
version: 1,
|
||||||
|
username: this.bootstrapUsername,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(created)
|
||||||
|
this.bootstrapUsername = null
|
||||||
|
return { username: created.username, passwordUserProvided: created.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: AuthFile = {
|
||||||
|
...current,
|
||||||
|
password: hashPassword(params.password),
|
||||||
|
userProvided: params.markUserProvided,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist(next)
|
||||||
|
return { username: next.username, passwordUserProvided: next.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): AuthStatus {
|
||||||
|
const current = this.load()
|
||||||
|
if (current) {
|
||||||
|
return { username: current.username, passwordUserProvided: current.userProvided }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.bootstrapUsername) {
|
||||||
|
return { username: this.bootstrapUsername, passwordUserProvided: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Auth is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(auth: AuthFile) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
|
||||||
|
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
|
||||||
|
this.cachedFile = auth
|
||||||
|
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
packages/server/src/auth/http-auth.ts
Normal file
38
packages/server/src/auth/http-auth.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
|
||||||
|
export function parseCookies(header: string | undefined): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
if (!header) return result
|
||||||
|
|
||||||
|
const parts = header.split(";")
|
||||||
|
for (const part of parts) {
|
||||||
|
const index = part.indexOf("=")
|
||||||
|
if (index < 0) continue
|
||||||
|
const key = part.slice(0, index).trim()
|
||||||
|
const value = part.slice(index + 1).trim()
|
||||||
|
if (!key) continue
|
||||||
|
result[key] = decodeURIComponent(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
|
||||||
|
if (!remoteAddress) return false
|
||||||
|
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
|
||||||
|
if (remoteAddress === "::ffff:127.0.0.1") return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wantsHtml(request: FastifyRequest): boolean {
|
||||||
|
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
|
||||||
|
return accept.includes("text/html") || accept.includes("application/xhtml")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
113
packages/server/src/auth/manager.ts
Normal file
113
packages/server/src/auth/manager.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import path from "path"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { AuthStore } from "./auth-store"
|
||||||
|
import { TokenManager } from "./token-manager"
|
||||||
|
import { SessionManager } from "./session-manager"
|
||||||
|
import { isLoopbackAddress, parseCookies } from "./http-auth"
|
||||||
|
|
||||||
|
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
|
||||||
|
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
|
||||||
|
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
|
||||||
|
|
||||||
|
export interface AuthManagerInit {
|
||||||
|
configPath: string
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
generateToken: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthManager {
|
||||||
|
private readonly authStore: AuthStore
|
||||||
|
private readonly tokenManager: TokenManager | null
|
||||||
|
private readonly sessionManager = new SessionManager()
|
||||||
|
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||||
|
|
||||||
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
|
const authFilePath = resolveAuthFilePath(init.configPath)
|
||||||
|
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
|
||||||
|
|
||||||
|
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
|
||||||
|
this.authStore.ensureInitialized({
|
||||||
|
username: init.username,
|
||||||
|
password: init.password,
|
||||||
|
allowBootstrapWithoutPassword: init.generateToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
getCookieName(): string {
|
||||||
|
return this.cookieName
|
||||||
|
}
|
||||||
|
|
||||||
|
isTokenBootstrapEnabled(): boolean {
|
||||||
|
return Boolean(this.tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
issueBootstrapToken(): string | null {
|
||||||
|
if (!this.tokenManager) return null
|
||||||
|
return this.tokenManager.generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeBootstrapToken(token: string): boolean {
|
||||||
|
if (!this.tokenManager) return false
|
||||||
|
return this.tokenManager.consume(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateLogin(username: string, password: string): boolean {
|
||||||
|
return this.authStore.validateCredentials(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(username: string) {
|
||||||
|
return this.sessionManager.createSession(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.authStore.getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassword(password: string) {
|
||||||
|
return this.authStore.setPassword({ password, markUserProvided: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoopbackRequest(request: FastifyRequest): boolean {
|
||||||
|
return isLoopbackAddress(request.socket.remoteAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
|
||||||
|
const cookies = parseCookies(request.headers.cookie)
|
||||||
|
const sessionId = cookies[this.cookieName]
|
||||||
|
const session = this.sessionManager.getSession(sessionId)
|
||||||
|
if (!session) return null
|
||||||
|
return { username: session.username, sessionId: session.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionCookie(reply: FastifyReply, sessionId: string) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(reply: FastifyReply) {
|
||||||
|
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthFilePath(configPath: string) {
|
||||||
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(filePath: string) {
|
||||||
|
if (filePath.startsWith("~/")) {
|
||||||
|
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
|
||||||
|
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
|
||||||
|
if (options?.maxAgeSeconds !== undefined) {
|
||||||
|
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
|
||||||
|
}
|
||||||
|
return parts.join("; ")
|
||||||
|
}
|
||||||
49
packages/server/src/auth/password-hash.ts
Normal file
49
packages/server/src/auth/password-hash.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface PasswordHashRecord {
|
||||||
|
algorithm: "scrypt"
|
||||||
|
saltBase64: string
|
||||||
|
hashBase64: string
|
||||||
|
keyLength: number
|
||||||
|
params: {
|
||||||
|
N: number
|
||||||
|
r: number
|
||||||
|
p: number
|
||||||
|
maxmem: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCRYPT_PARAMS = {
|
||||||
|
N: 16384,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
maxmem: 32 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password: string): PasswordHashRecord {
|
||||||
|
const salt = crypto.randomBytes(16)
|
||||||
|
const params = DEFAULT_SCRYPT_PARAMS
|
||||||
|
const keyLength = 64
|
||||||
|
const derived = crypto.scryptSync(password, salt, keyLength, params)
|
||||||
|
return {
|
||||||
|
algorithm: "scrypt",
|
||||||
|
saltBase64: salt.toString("base64"),
|
||||||
|
hashBase64: Buffer.from(derived).toString("base64"),
|
||||||
|
keyLength,
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
|
||||||
|
if (record.algorithm !== "scrypt") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = Buffer.from(record.saltBase64, "base64")
|
||||||
|
const expected = Buffer.from(record.hashBase64, "base64")
|
||||||
|
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
|
||||||
|
if (expected.length !== derived.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(expected, Buffer.from(derived))
|
||||||
|
}
|
||||||
23
packages/server/src/auth/session-manager.ts
Normal file
23
packages/server/src/auth/session-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
private sessions = new Map<string, SessionInfo>()
|
||||||
|
|
||||||
|
createSession(username: string): SessionInfo {
|
||||||
|
const id = crypto.randomBytes(32).toString("base64url")
|
||||||
|
const info: SessionInfo = { id, createdAt: Date.now(), username }
|
||||||
|
this.sessions.set(id, info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(id: string | undefined): SessionInfo | undefined {
|
||||||
|
if (!id) return undefined
|
||||||
|
return this.sessions.get(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/server/src/auth/token-manager.ts
Normal file
32
packages/server/src/auth/token-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
export interface BootstrapToken {
|
||||||
|
token: string
|
||||||
|
createdAt: number
|
||||||
|
consumed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenManager {
|
||||||
|
private token: BootstrapToken | null = null
|
||||||
|
|
||||||
|
constructor(private readonly ttlMs: number) {}
|
||||||
|
|
||||||
|
generate(): string {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url")
|
||||||
|
this.token = { token, createdAt: Date.now(), consumed: false }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
consume(token: string): boolean {
|
||||||
|
if (!this.token) return false
|
||||||
|
if (this.token.consumed) return false
|
||||||
|
if (Date.now() - this.token.createdAt > this.ttlMs) return false
|
||||||
|
if (token !== this.token.token) return false
|
||||||
|
this.token.consumed = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
peek(): string | null {
|
||||||
|
return this.token?.token ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
@@ -11,6 +11,7 @@ const ROOT_DIR = ".codenomad/background_processes"
|
|||||||
const INDEX_FILE = "index.json"
|
const INDEX_FILE = "index.json"
|
||||||
const OUTPUT_FILE = "output.txt"
|
const OUTPUT_FILE = "output.txt"
|
||||||
const STOP_TIMEOUT_MS = 2000
|
const STOP_TIMEOUT_MS = 2000
|
||||||
|
const EXIT_WAIT_TIMEOUT_MS = 5000
|
||||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface ManagerDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RunningProcess {
|
interface RunningProcess {
|
||||||
|
id: string
|
||||||
child: ChildProcess
|
child: ChildProcess
|
||||||
outputPath: string
|
outputPath: string
|
||||||
exitPromise: Promise<void>
|
exitPromise: Promise<void>
|
||||||
@@ -58,12 +60,21 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||||
|
|
||||||
const child = spawn("bash", ["-c", command], {
|
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||||
|
|
||||||
|
const child = spawn(shellCommand, shellArgs, {
|
||||||
cwd: workspace.path,
|
cwd: workspace.path,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
...spawnOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", () => {
|
||||||
|
this.killProcessTree(child, "SIGTERM")
|
||||||
})
|
})
|
||||||
|
|
||||||
const record: BackgroundProcess = {
|
const record: BackgroundProcess = {
|
||||||
|
|
||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
title,
|
title,
|
||||||
@@ -91,7 +102,7 @@ export class BackgroundProcessManager {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
|
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
|
||||||
|
|
||||||
let lastPublishAt = 0
|
let lastPublishAt = 0
|
||||||
const maybePublishSize = () => {
|
const maybePublishSize = () => {
|
||||||
@@ -128,7 +139,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +160,7 @@ export class BackgroundProcessManager {
|
|||||||
|
|
||||||
const running = this.running.get(processId)
|
const running = this.running.get(processId)
|
||||||
if (running?.child && !running.child.killed) {
|
if (running?.child && !running.child.killed) {
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,24 +266,94 @@ export class BackgroundProcessManager {
|
|||||||
private async cleanupWorkspace(workspaceId: string) {
|
private async cleanupWorkspace(workspaceId: string) {
|
||||||
for (const [, running] of this.running.entries()) {
|
for (const [, running] of this.running.entries()) {
|
||||||
if (running.workspaceId !== workspaceId) continue
|
if (running.workspaceId !== workspaceId) continue
|
||||||
running.child.kill("SIGTERM")
|
this.killProcessTree(running.child, "SIGTERM")
|
||||||
await this.waitForExit(running)
|
await this.waitForExit(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.removeWorkspaceDir(workspaceId)
|
await this.removeWorkspaceDir(workspaceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) return
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||||
|
try {
|
||||||
|
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// Fall back to killing the direct child.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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) {
|
private async waitForExit(running: RunningProcess) {
|
||||||
let resolved = false
|
let exited = false
|
||||||
const timeout = setTimeout(() => {
|
const exitPromise = running.exitPromise.finally(() => {
|
||||||
if (!resolved) {
|
exited = true
|
||||||
running.child.kill("SIGKILL")
|
})
|
||||||
|
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
if (!exited) {
|
||||||
|
this.killProcessTree(running.child, "SIGKILL")
|
||||||
}
|
}
|
||||||
}, STOP_TIMEOUT_MS)
|
}, STOP_TIMEOUT_MS)
|
||||||
|
|
||||||
await running.exitPromise.finally(() => {
|
try {
|
||||||
resolved = true
|
await Promise.race([
|
||||||
clearTimeout(timeout)
|
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 buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
return {
|
||||||
|
shellCommand: comspec,
|
||||||
|
shellArgs: ["/d", "/s", "/c", command],
|
||||||
|
spawnOptions: { windowsVerbatimArguments: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep bash for macOS/Linux.
|
||||||
|
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||||
|
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||||
|
const force = signal === "SIGKILL"
|
||||||
|
const args = ["/PID", String(pid), "/T"]
|
||||||
|
if (force) {
|
||||||
|
args.push("/F")
|
||||||
|
}
|
||||||
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
BinaryUpdateRequest,
|
BinaryUpdateRequest,
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
} from "../api-types"
|
} from "../api-types"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
import { ConfigStore } from "./store"
|
import { ConfigStore } from "./store"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { ConfigFile } from "./schema"
|
import type { ConfigFile } from "./schema"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||||
|
|
||||||
export class BinaryRegistry {
|
export class BinaryRegistry {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -135,8 +137,42 @@ export class BinaryRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||||
// TODO: call actual binary -v check.
|
const inputPath = record.path
|
||||||
return { valid: true, version: record.version }
|
if (!inputPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = buildSpawnSpec(inputPath, ["--version"])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = (result.stdout ?? "").trim()
|
||||||
|
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||||
|
const normalized = firstLine?.trim()
|
||||||
|
|
||||||
|
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
|
||||||
|
return { valid: true, version }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFallbackRecord(path: string): BinaryRecord {
|
private buildFallbackRecord(path: string): BinaryRecord {
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ const PreferencesSchema = z.object({
|
|||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
|
locale: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
modelFavorites: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
this.on("app.releaseAvailable", handler)
|
|
||||||
return () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
this.off("app.releaseAvailable", handler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import {
|
import {
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
FileSystemListingMetadata,
|
FileSystemListingMetadata,
|
||||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
|||||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
|
||||||
|
const name = this.normalizeFolderName(folderName)
|
||||||
|
|
||||||
|
if (this.unrestricted) {
|
||||||
|
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
|
||||||
|
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
|
||||||
|
throw new Error("Cannot create folders at drive root")
|
||||||
|
}
|
||||||
|
this.assertDirectoryExists(resolvedParent)
|
||||||
|
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
|
||||||
|
fs.mkdirSync(absolutePath)
|
||||||
|
return { path: absolutePath, absolutePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedParent = this.normalizeRelativePath(parentPath)
|
||||||
|
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
|
||||||
|
this.assertDirectoryExists(parentAbsolute)
|
||||||
|
|
||||||
|
const relativePath = this.buildRelativePath(normalizedParent, name)
|
||||||
|
const absolutePath = this.toRestrictedAbsolute(relativePath)
|
||||||
|
fs.mkdirSync(absolutePath)
|
||||||
|
return { path: relativePath, absolutePath }
|
||||||
|
}
|
||||||
|
|
||||||
readFile(relativePath: string): string {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
|
|||||||
return { entries, metadata }
|
return { entries, metadata }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeFolderName(input: string): string {
|
||||||
|
const name = input.trim()
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Folder name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("~")) {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("/") || name.includes("\\")) {
|
||||||
|
throw new Error("Folder name must not include path separators")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("\u0000")) {
|
||||||
|
throw new Error("Invalid folder name")
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertDirectoryExists(directory: string) {
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
throw new Error(`Directory does not exist: ${directory}`)
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(directory)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Path is not a directory: ${directory}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
const results: FileSystemEntry[] = []
|
const results: FileSystemEntry[] = []
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { InstanceStore } from "./storage/instance-store"
|
|||||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -36,7 +37,13 @@ interface CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiAutoUpdate: boolean
|
||||||
|
uiNoUpdate: boolean
|
||||||
|
uiManifestUrl?: string
|
||||||
launch: boolean
|
launch: boolean
|
||||||
|
authUsername: string
|
||||||
|
authPassword?: string
|
||||||
|
generateToken: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PORT = 9898
|
const DEFAULT_PORT = 9898
|
||||||
@@ -62,7 +69,21 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||||
|
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
|
||||||
|
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
|
||||||
|
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
|
||||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||||
|
.addOption(
|
||||||
|
new Option("--username <username>", "Username for server authentication")
|
||||||
|
.env("CODENOMAD_SERVER_USERNAME")
|
||||||
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
|
)
|
||||||
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
|
.addOption(
|
||||||
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
|
||||||
program.parse(argv, { from: "user" })
|
program.parse(argv, { from: "user" })
|
||||||
const parsed = program.opts<{
|
const parsed = program.opts<{
|
||||||
@@ -76,13 +97,22 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination?: string
|
logDestination?: string
|
||||||
uiDir: string
|
uiDir: string
|
||||||
uiDevServer?: string
|
uiDevServer?: string
|
||||||
|
uiNoUpdate?: boolean
|
||||||
|
uiAutoUpdate?: string
|
||||||
|
uiManifestUrl?: string
|
||||||
launch?: boolean
|
launch?: boolean
|
||||||
|
username: string
|
||||||
|
password?: string
|
||||||
|
generateToken?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
const normalizedHost = resolveHost(parsed.host)
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
|
|
||||||
|
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
|
||||||
|
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: parsed.port,
|
port: parsed.port,
|
||||||
host: normalizedHost,
|
host: normalizedHost,
|
||||||
@@ -93,7 +123,13 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
logDestination: parsed.logDestination,
|
logDestination: parsed.logDestination,
|
||||||
uiStaticDir: parsed.uiDir,
|
uiStaticDir: parsed.uiDir,
|
||||||
uiDevServer: parsed.uiDevServer,
|
uiDevServer: parsed.uiDevServer,
|
||||||
|
uiAutoUpdate,
|
||||||
|
uiNoUpdate: Boolean(parsed.uiNoUpdate),
|
||||||
|
uiManifestUrl: parsed.uiManifestUrl,
|
||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
|
authUsername: parsed.username,
|
||||||
|
authPassword: parsed.password,
|
||||||
|
generateToken: Boolean(parsed.generateToken),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +142,22 @@ function parsePort(input: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveHost(input: string | undefined): string {
|
function resolveHost(input: string | undefined): string {
|
||||||
if (input && input.trim() === "0.0.0.0") {
|
const trimmed = input?.trim()
|
||||||
|
if (!trimmed) return DEFAULT_HOST
|
||||||
|
|
||||||
|
if (trimmed === "0.0.0.0") {
|
||||||
return "0.0.0.0"
|
return "0.0.0.0"
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST
|
|
||||||
|
if (trimmed === "localhost") {
|
||||||
|
return DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -119,21 +167,45 @@ async function main() {
|
|||||||
const configLogger = logger.child({ component: "config" })
|
const configLogger = logger.child({ component: "config" })
|
||||||
const eventLogger = logger.child({ component: "events" })
|
const eventLogger = logger.child({ component: "events" })
|
||||||
|
|
||||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
const logOptions = {
|
||||||
|
...options,
|
||||||
|
authPassword: options.authPassword ? "[REDACTED]" : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
|
||||||
|
|
||||||
const eventBus = new EventBus(eventLogger)
|
const eventBus = new EventBus(eventLogger)
|
||||||
|
|
||||||
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
eventsUrl: `/api/events`,
|
eventsUrl: `/api/events`,
|
||||||
host: options.host,
|
host: options.host,
|
||||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||||
port: options.port,
|
port: options.port,
|
||||||
hostLabel: options.host,
|
hostLabel: options.host,
|
||||||
workspaceRoot: options.rootDir,
|
workspaceRoot: options.rootDir,
|
||||||
addresses: [],
|
addresses: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authManager = new AuthManager(
|
||||||
|
{
|
||||||
|
configPath: options.configPath,
|
||||||
|
username: options.authUsername,
|
||||||
|
password: options.authPassword,
|
||||||
|
generateToken: options.generateToken,
|
||||||
|
},
|
||||||
|
logger.child({ component: "auth" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (options.generateToken) {
|
||||||
|
const token = authManager.issueBootstrapToken()
|
||||||
|
if (token) {
|
||||||
|
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
@@ -152,19 +224,36 @@ async function main() {
|
|||||||
logger: logger.child({ component: "instance-events" }),
|
logger: logger.child({ component: "instance-events" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const releaseMonitor = startReleaseMonitor({
|
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
|
||||||
currentVersion: packageJson.version,
|
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
|
||||||
logger: logger.child({ component: "release-monitor" }),
|
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
|
||||||
onUpdate: (release) => {
|
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
|
||||||
if (release) {
|
|
||||||
serverMeta.latestRelease = release
|
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
|
||||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
|
||||||
} else {
|
const uiResolution = await resolveUi({
|
||||||
delete serverMeta.latestRelease
|
serverVersion: packageJson.version,
|
||||||
}
|
bundledUiDir: DEFAULT_UI_STATIC_DIR,
|
||||||
},
|
autoUpdate: autoUpdateEnabled,
|
||||||
|
overrideUiDir: uiDirOverride,
|
||||||
|
uiDevServerUrl: options.uiDevServer,
|
||||||
|
manifestUrl: options.uiManifestUrl,
|
||||||
|
logger: logger.child({ component: "ui" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
serverMeta.serverVersion = packageJson.version
|
||||||
|
serverMeta.ui = {
|
||||||
|
version: uiResolution.uiVersion,
|
||||||
|
source: uiResolution.source,
|
||||||
|
}
|
||||||
|
serverMeta.support = {
|
||||||
|
supported: uiResolution.supported,
|
||||||
|
message: uiResolution.message,
|
||||||
|
latestServerVersion: uiResolution.latestServerVersion,
|
||||||
|
latestServerUrl: uiResolution.latestServerUrl,
|
||||||
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
|
}
|
||||||
|
|
||||||
const server = createHttpServer({
|
const server = createHttpServer({
|
||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
@@ -175,8 +264,9 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
uiStaticDir: options.uiStaticDir,
|
authManager,
|
||||||
uiDevServerUrl: options.uiDevServer,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,23 +286,35 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
logger.info("Received shutdown signal, closing server")
|
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||||
try {
|
|
||||||
await server.stop()
|
|
||||||
logger.info("HTTP server stopped")
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const shutdownWorkspaces = (async () => {
|
||||||
instanceEventBridge.shutdown()
|
try {
|
||||||
await workspaceManager.shutdown()
|
instanceEventBridge.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
} catch (error) {
|
||||||
} catch (error) {
|
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
releaseMonitor.stop()
|
try {
|
||||||
|
await workspaceManager.shutdown()
|
||||||
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const shutdownHttp = (async () => {
|
||||||
|
try {
|
||||||
|
await server.stop()
|
||||||
|
logger.info("HTTP server stopped")
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||||
|
|
||||||
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
|||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
|
import type { AuthManager } from "../auth/manager"
|
||||||
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
host: string
|
host: string
|
||||||
@@ -34,6 +37,7 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -88,8 +92,42 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||||
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: true,
|
origin: (origin, cb) => {
|
||||||
|
if (!origin) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let selfOrigin: string | null = null
|
||||||
|
try {
|
||||||
|
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
|
||||||
|
} catch {
|
||||||
|
selfOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfOrigin && origin === selfOrigin) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedDevOrigins.has(origin)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||||
|
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||||
|
cb(null, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
cb(null, false)
|
||||||
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,6 +147,76 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
|
app.addHook("preHandler", (request, reply, done) => {
|
||||||
|
const rawUrl = request.raw.url ?? request.url
|
||||||
|
const pathname = (rawUrl.split("?")[0] ?? "").trim()
|
||||||
|
|
||||||
|
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
|
||||||
|
const publicPagePaths = new Set(["/login"])
|
||||||
|
if (deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
publicPagePaths.add("/auth/token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
|
||||||
|
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
|
||||||
|
if (requiresAuthForApi && !session) {
|
||||||
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
||||||
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
|
||||||
|
if (pluginMatch) {
|
||||||
|
const workspaceId = pluginMatch[1]
|
||||||
|
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
const provided = Array.isArray(request.headers.authorization)
|
||||||
|
? request.headers.authorization[0]
|
||||||
|
: request.headers.authorization
|
||||||
|
|
||||||
|
if (expected && provided && provided === expected) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUnauthorized(request, reply)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.uiDevServerUrl) {
|
||||||
|
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiDir = deps.uiStaticDir
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
if (uiDir && fs.existsSync(indexPath)) {
|
||||||
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(404).send({ message: "UI bundle missing" })
|
||||||
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
@@ -125,9 +233,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
|
|
||||||
if (deps.uiDevServerUrl) {
|
if (deps.uiDevServerUrl) {
|
||||||
setupDevProxy(app, deps.uiDevServerUrl)
|
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
|
||||||
} else {
|
} else {
|
||||||
setupStaticUi(app, deps.uiStaticDir)
|
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -175,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||||
|
|
||||||
deps.serverMeta.httpBaseUrl = serverUrl
|
deps.serverMeta.httpBaseUrl = serverUrl
|
||||||
deps.serverMeta.host = deps.host
|
deps.serverMeta.host = deps.host
|
||||||
deps.serverMeta.port = actualPort
|
deps.serverMeta.port = actualPort
|
||||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||||
|
|
||||||
@@ -260,6 +368,7 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
|
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
|
||||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (logger.isLevelEnabled("trace")) {
|
||||||
@@ -267,6 +376,12 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
return reply.from(targetUrl, {
|
||||||
|
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||||
|
if (instanceAuthHeader) {
|
||||||
|
headers.authorization = instanceAuthHeader
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
},
|
||||||
onError: (proxyReply, { error }) => {
|
onError: (proxyReply, { error }) => {
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
if (!proxyReply.sent) {
|
if (!proxyReply.sent) {
|
||||||
@@ -284,7 +399,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
|||||||
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
return trimmed.length === 0 ? "/" : `/${trimmed}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
|
||||||
if (!uiDir) {
|
if (!uiDir) {
|
||||||
app.log.warn("UI static directory not provided; API endpoints only")
|
app.log.warn("UI static directory not provided; API endpoints only")
|
||||||
return
|
return
|
||||||
@@ -310,6 +425,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (fs.existsSync(indexPath)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
|
||||||
} else {
|
} else {
|
||||||
@@ -318,7 +439,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
|
||||||
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
|
||||||
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const url = request.raw.url ?? ""
|
const url = request.raw.url ?? ""
|
||||||
@@ -326,6 +447,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
|
|||||||
reply.code(404).send({ message: "Not Found" })
|
reply.code(404).send({ message: "Not Found" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = authManager.getSessionFromRequest(request)
|
||||||
|
if (!session && wantsHtml(request)) {
|
||||||
|
reply.redirect("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void proxyToDevServer(request, reply, upstreamBase)
|
void proxyToDevServer(request, reply, upstreamBase)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
134
packages/server/src/server/routes/auth-pages/login.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad Login</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: #0f0f16;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: #4c6fff;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
||||||
|
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" value="" />
|
||||||
|
|
||||||
|
<button id="submit" type="button">Continue</button>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id)
|
||||||
|
const errorEl = $("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
const hideError = () => {
|
||||||
|
errorEl.textContent = ""
|
||||||
|
errorEl.style.display = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
hideError()
|
||||||
|
const username = $("username").value.trim()
|
||||||
|
const password = $("password").value
|
||||||
|
if (!username || !password) {
|
||||||
|
showError("Username and password are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Login failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = "/"
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("submit").addEventListener("click", submit)
|
||||||
|
$("password").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") submit()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
93
packages/server/src/server/routes/auth-pages/token.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
|
background: #0b0b0f;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Connecting…</h1>
|
||||||
|
<p>Finalizing local authentication.</p>
|
||||||
|
<div id="error" class="error" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const token = (location.hash || "").replace(/^#/, "").trim()
|
||||||
|
const errorEl = document.getElementById("error")
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorEl.textContent = msg
|
||||||
|
errorEl.style.display = "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!token) {
|
||||||
|
showError("Missing bootstrap token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = ""
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
message = json && json.error ? String(json.error) : ""
|
||||||
|
} catch {
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
showError(message || `Token exchange failed (${res.status})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.replace("/")
|
||||||
|
} catch (e) {
|
||||||
|
showError(e && e.message ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
157
packages/server/src/server/routes/auth.ts
Normal file
157
packages/server/src/server/routes/auth.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import fs from "fs"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { AuthManager } from "../../auth/manager"
|
||||||
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
authManager: AuthManager
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const TokenSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
||||||
|
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
||||||
|
|
||||||
|
let cachedLoginTemplate: string | null = null
|
||||||
|
let cachedTokenTemplate: string | null = null
|
||||||
|
|
||||||
|
function readTemplate(url: URL, cache: string | null): string {
|
||||||
|
if (cache) return cache
|
||||||
|
const content = fs.readFileSync(url, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
|
if (!cachedLoginTemplate) {
|
||||||
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
|
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenHtml(): string {
|
||||||
|
if (!cachedTokenTemplate) {
|
||||||
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedTokenTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/login", async (_request, reply) => {
|
||||||
|
const status = deps.authManager.getStatus()
|
||||||
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.type("text/html").send(getTokenHtml())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/auth/status", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.send({ authenticated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
|
const body = LoginSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.validateLogin(body.username, body.password)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid credentials" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deps.authManager.createSession(body.username)
|
||||||
|
deps.authManager.setSessionCookie(reply, session.id)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/token", async (request, reply) => {
|
||||||
|
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404).send({ error: "Not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = TokenSchema.parse(request.body ?? {})
|
||||||
|
const ok = deps.authManager.consumeBootstrapToken(body.token)
|
||||||
|
if (!ok) {
|
||||||
|
reply.code(401).send({ error: "Invalid token" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = deps.authManager.getStatus().username
|
||||||
|
const session = deps.authManager.createSession(username)
|
||||||
|
deps.authManager.setSessionCookie(reply, session.id)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", async (_request, reply) => {
|
||||||
|
deps.authManager.clearSessionCookie(reply)
|
||||||
|
reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/auth/password", async (request, reply) => {
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (!session) {
|
||||||
|
reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = PasswordSchema.parse(request.body ?? {})
|
||||||
|
try {
|
||||||
|
const status = deps.authManager.setPassword(body.password)
|
||||||
|
reply.send({ ok: true, ...status })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
reply.code(409).type("text/plain").send(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value.replace(/[&<>"]/g, (char) => {
|
||||||
|
switch (char) {
|
||||||
|
case "&":
|
||||||
|
return "&"
|
||||||
|
case "<":
|
||||||
|
return "<"
|
||||||
|
case ">":
|
||||||
|
return ">"
|
||||||
|
case '"':
|
||||||
|
return """
|
||||||
|
default:
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
|||||||
includeFiles: z.coerce.boolean().optional(),
|
includeFiles: z.coerce.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FilesystemCreateFolderSchema = z.object({
|
||||||
|
parentPath: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/filesystem", async (request, reply) => {
|
app.get("/api/filesystem", async (request, reply) => {
|
||||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
|||||||
return { error: (error as Error).message }
|
return { error: (error as Error).message }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post("/api/filesystem/folders", async (request, reply) => {
|
||||||
|
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
|
||||||
|
reply.code(201)
|
||||||
|
return created
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException
|
||||||
|
if (err?.code === "EEXIST") {
|
||||||
|
reply.code(409).type("text/plain").send("Folder already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
||||||
|
reply.code(403).type("text/plain").send("Permission denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(400).type("text/plain").send((error as Error).message)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
|||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
port,
|
port,
|
||||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
addresses,
|
addresses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,10 @@ function resolvePort(meta: ServerMeta): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(host: string): boolean {
|
||||||
|
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||||
const interfaces = os.networkInterfaces()
|
const interfaces = os.networkInterfaces()
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|||||||
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { afterEach, beforeEach, describe, it } from "node:test"
|
||||||
|
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { resolveUi } from "../remote-ui"
|
||||||
|
|
||||||
|
const noopLogger: Logger = {
|
||||||
|
debug: () => {},
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
trace: () => {},
|
||||||
|
child: () => noopLogger,
|
||||||
|
isLevelEnabled: () => false,
|
||||||
|
} as any
|
||||||
|
|
||||||
|
let tempRoot: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resolveUi local version preference", () => {
|
||||||
|
it("prefers bundled when bundled version is higher", async () => {
|
||||||
|
const bundledDir = path.join(tempRoot, "bundled")
|
||||||
|
const configDir = path.join(tempRoot, "config")
|
||||||
|
const currentDir = path.join(configDir, "ui", "current")
|
||||||
|
|
||||||
|
await mkdir(bundledDir, { recursive: true })
|
||||||
|
await mkdir(currentDir, { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||||
|
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||||
|
|
||||||
|
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||||
|
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" }))
|
||||||
|
|
||||||
|
const result = await resolveUi({
|
||||||
|
serverVersion: "0.8.1",
|
||||||
|
bundledUiDir: bundledDir,
|
||||||
|
autoUpdate: false,
|
||||||
|
configDir,
|
||||||
|
logger: noopLogger,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.source, "bundled")
|
||||||
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
|
})
|
||||||
|
})
|
||||||
571
packages/server/src/ui/remote-ui.ts
Normal file
571
packages/server/src/ui/remote-ui.ts
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
import { createHash } from "crypto"
|
||||||
|
import fs from "fs"
|
||||||
|
import { promises as fsp } from "fs"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { fetch } from "undici"
|
||||||
|
import yauzl from "yauzl"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
|
||||||
|
export interface RemoteUiManifest {
|
||||||
|
minServerVersion: string
|
||||||
|
latestUIVersion: string
|
||||||
|
uiPackageURL: string
|
||||||
|
sha256: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
|
||||||
|
|
||||||
|
export interface UiResolution {
|
||||||
|
uiStaticDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
source: UiSource
|
||||||
|
uiVersion?: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteUiOptions {
|
||||||
|
serverVersion: string
|
||||||
|
bundledUiDir: string
|
||||||
|
autoUpdate: boolean
|
||||||
|
overrideUiDir?: string
|
||||||
|
uiDevServerUrl?: string
|
||||||
|
manifestUrl?: string
|
||||||
|
configDir?: string
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
|
||||||
|
|
||||||
|
const MANIFEST_TIMEOUT_MS = 5_000
|
||||||
|
const ZIP_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
|
||||||
|
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
|
||||||
|
|
||||||
|
if (options.uiDevServerUrl) {
|
||||||
|
return {
|
||||||
|
uiDevServerUrl: options.uiDevServerUrl,
|
||||||
|
source: "dev-proxy",
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.overrideUiDir) {
|
||||||
|
const resolved = await resolveStaticUiDir(options.overrideUiDir)
|
||||||
|
return {
|
||||||
|
uiStaticDir: resolved ?? options.overrideUiDir,
|
||||||
|
source: "override",
|
||||||
|
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
|
||||||
|
supported: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiRoot = resolveUiCacheRoot(options.configDir)
|
||||||
|
const currentDir = path.join(uiRoot, "current")
|
||||||
|
const previousDir = path.join(uiRoot, "previous")
|
||||||
|
|
||||||
|
if (!options.autoUpdate) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest: RemoteUiManifest | null = null
|
||||||
|
try {
|
||||||
|
manifest = await fetchManifest(manifestUrl, options.logger)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
|
||||||
|
if (!supported) {
|
||||||
|
const message = "Upgrade App to use latest features"
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: false,
|
||||||
|
message,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestLocal = await pickBestLocalUi({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteIsNewer =
|
||||||
|
!bestLocal ||
|
||||||
|
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||||
|
|
||||||
|
if (!remoteIsNewer) {
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installRemoteUi({
|
||||||
|
manifest,
|
||||||
|
uiRoot,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
logger: options.logger,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = await resolveStaticUiDir(currentDir)
|
||||||
|
if (installed) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: installed,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(installed),
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolveFromCacheOrBundled({
|
||||||
|
logger: options.logger,
|
||||||
|
bundledUiDir: options.bundledUiDir,
|
||||||
|
currentDir,
|
||||||
|
previousDir,
|
||||||
|
supported: true,
|
||||||
|
latestServerVersion: manifest.latestServerVersion,
|
||||||
|
latestServerUrl: manifest.latestServerUrl,
|
||||||
|
minServerVersion: manifest.minServerVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUiCacheRoot(configDir?: string): string {
|
||||||
|
if (configDir) {
|
||||||
|
return path.join(configDir, "ui")
|
||||||
|
}
|
||||||
|
return path.join(os.homedir(), ".config", "codenomad", "ui")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFromCacheOrBundled(args: {
|
||||||
|
logger: Logger
|
||||||
|
bundledUiDir: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
supported: boolean
|
||||||
|
message?: string
|
||||||
|
latestServerVersion?: string
|
||||||
|
latestServerUrl?: string
|
||||||
|
minServerVersion?: string
|
||||||
|
}): Promise<UiResolution> {
|
||||||
|
const bestLocal = await pickBestLocalUi({
|
||||||
|
logger: args.logger,
|
||||||
|
bundledUiDir: args.bundledUiDir,
|
||||||
|
currentDir: args.currentDir,
|
||||||
|
previousDir: args.previousDir,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (bestLocal) {
|
||||||
|
return {
|
||||||
|
uiStaticDir: bestLocal.uiStaticDir,
|
||||||
|
source: bestLocal.source,
|
||||||
|
uiVersion: bestLocal.uiVersion,
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
|
||||||
|
return {
|
||||||
|
uiStaticDir: args.bundledUiDir,
|
||||||
|
source: "missing",
|
||||||
|
supported: args.supported,
|
||||||
|
message: args.message,
|
||||||
|
latestServerVersion: args.latestServerVersion,
|
||||||
|
latestServerUrl: args.latestServerUrl,
|
||||||
|
minServerVersion: args.minServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickBestLocalUi(args: {
|
||||||
|
logger: Logger
|
||||||
|
bundledUiDir: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
|
||||||
|
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
|
||||||
|
|
||||||
|
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||||
|
if (currentResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: currentResolved,
|
||||||
|
source: "downloaded",
|
||||||
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
|
priority: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||||
|
if (bundledResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: bundledResolved,
|
||||||
|
source: "bundled",
|
||||||
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
|
priority: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||||
|
if (previousResolved) {
|
||||||
|
candidates.push({
|
||||||
|
uiStaticDir: previousResolved,
|
||||||
|
source: "previous",
|
||||||
|
uiVersion: await readUiVersion(previousResolved),
|
||||||
|
priority: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => {
|
||||||
|
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
|
||||||
|
if (versionCmp !== 0) return -versionCmp
|
||||||
|
return b.priority - a.priority
|
||||||
|
})
|
||||||
|
|
||||||
|
const best = candidates[0]
|
||||||
|
if (!best) return null
|
||||||
|
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
|
||||||
|
if (!a && !b) return 0
|
||||||
|
if (!a) return -1
|
||||||
|
if (!b) return 1
|
||||||
|
return compareSemverCore(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
await fsp.access(indexPath, fs.constants.R_OK)
|
||||||
|
return uiDir
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiVersionFile {
|
||||||
|
uiVersion?: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUiVersion(uiDir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
|
||||||
|
const parsed = JSON.parse(content) as UiVersionFile
|
||||||
|
return parsed.uiVersion ?? parsed.version
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Manifest responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as RemoteUiManifest
|
||||||
|
validateManifest(json)
|
||||||
|
return json
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(manifest: RemoteUiManifest) {
|
||||||
|
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
|
||||||
|
for (const key of required) {
|
||||||
|
const value = manifest[key]
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`Manifest missing ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
|
||||||
|
throw new Error("uiPackageURL must be https")
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
|
||||||
|
throw new Error("sha256 must be 64 hex chars")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRemoteUi(args: {
|
||||||
|
manifest: RemoteUiManifest
|
||||||
|
uiRoot: string
|
||||||
|
currentDir: string
|
||||||
|
previousDir: string
|
||||||
|
logger: Logger
|
||||||
|
}) {
|
||||||
|
await fsp.mkdir(args.uiRoot, { recursive: true })
|
||||||
|
|
||||||
|
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
|
||||||
|
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
|
||||||
|
const digest = await sha256File(zipPath)
|
||||||
|
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
|
||||||
|
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await extractZip(zipPath, tmpDir)
|
||||||
|
|
||||||
|
const indexPath = path.join(tmpDir, "index.html")
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error("Extracted UI missing index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
|
||||||
|
|
||||||
|
fs.rmSync(args.currentDir, { recursive: true, force: true })
|
||||||
|
fs.renameSync(tmpDir, args.currentDir)
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||||
|
fs.rmSync(zipPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(args.previousDir)) {
|
||||||
|
fs.rmSync(args.previousDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
if (fs.existsSync(args.currentDir)) {
|
||||||
|
fs.renameSync(args.currentDir, args.previousDir)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(url: string, targetPath: string, logger: Logger) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/octet-stream",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`UI zip download failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||||
|
const fileStream = fs.createWriteStream(targetPath)
|
||||||
|
|
||||||
|
const body = response.body
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("UI zip response missing body")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeStream = Readable.fromWeb(body as any)
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
nodeStream.pipe(fileStream)
|
||||||
|
nodeStream.on("error", reject)
|
||||||
|
fileStream.on("error", reject)
|
||||||
|
fileStream.on("finish", () => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(filePath: string): Promise<string> {
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk))
|
||||||
|
stream.on("error", reject)
|
||||||
|
stream.on("end", () => resolve())
|
||||||
|
})
|
||||||
|
return hash.digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
|
||||||
|
if (openErr || !zipfile) {
|
||||||
|
reject(openErr ?? new Error("Unable to open zip"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = path.resolve(targetDir)
|
||||||
|
|
||||||
|
const closeWithError = (error: unknown) => {
|
||||||
|
try {
|
||||||
|
zipfile.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipfile.readEntry()
|
||||||
|
|
||||||
|
zipfile.on("entry", (entry) => {
|
||||||
|
// Normalize and guard against zip-slip.
|
||||||
|
const entryPath = entry.fileName.replace(/\\/g, "/")
|
||||||
|
|
||||||
|
const segments = entryPath.split("/").filter(Boolean)
|
||||||
|
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
|
||||||
|
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = path.resolve(targetDir, entryPath)
|
||||||
|
if (!destination.startsWith(root + path.sep) && destination !== root) {
|
||||||
|
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectory = entry.fileName.endsWith("/")
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
fsp
|
||||||
|
.mkdir(destination, { recursive: true })
|
||||||
|
.then(() => zipfile.readEntry())
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsp
|
||||||
|
.mkdir(path.dirname(destination), { recursive: true })
|
||||||
|
.then(() => {
|
||||||
|
zipfile.openReadStream(entry, (streamErr, readStream) => {
|
||||||
|
if (streamErr || !readStream) {
|
||||||
|
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeStream = fs.createWriteStream(destination)
|
||||||
|
const cleanup = (error?: unknown) => {
|
||||||
|
readStream.destroy()
|
||||||
|
writeStream.destroy()
|
||||||
|
if (error) {
|
||||||
|
closeWithError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readStream.on("error", cleanup)
|
||||||
|
writeStream.on("error", cleanup)
|
||||||
|
writeStream.on("finish", () => zipfile.readEntry())
|
||||||
|
|
||||||
|
readStream.pipe(writeStream)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("end", () => {
|
||||||
|
zipfile.close()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
zipfile.on("error", (error) => closeWithError(error))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemverCore(a: string, b: string): number {
|
||||||
|
const pa = parseSemverCore(a)
|
||||||
|
const pb = parseSemverCore(b)
|
||||||
|
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
|
||||||
|
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
|
||||||
|
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
|
||||||
|
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
|
||||||
|
const parts = core.split(".")
|
||||||
|
const parsePart = (input: string | undefined) => {
|
||||||
|
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
major: parsePart(parts[0]),
|
||||||
|
minor: parsePart(parts[1]),
|
||||||
|
patch: parsePart(parts[2]),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,8 +96,15 @@ export class InstanceEventBridge {
|
|||||||
|
|
||||||
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
const url = `http://${INSTANCE_HOST}:${port}/event`
|
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { Accept: "text/event-stream" }
|
||||||
|
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: "text/event-stream" },
|
headers,
|
||||||
signal,
|
signal,
|
||||||
dispatcher: STREAM_AGENT,
|
dispatcher: STREAM_AGENT,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
|
|||||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||||
|
import {
|
||||||
|
buildOpencodeBasicAuthHeader,
|
||||||
|
DEFAULT_OPENCODE_USERNAME,
|
||||||
|
generateOpencodeServerPassword,
|
||||||
|
OPENCODE_SERVER_PASSWORD_ENV,
|
||||||
|
OPENCODE_SERVER_USERNAME_ENV,
|
||||||
|
} from "./opencode-auth"
|
||||||
|
|
||||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
@@ -29,6 +36,7 @@ export class WorkspaceManager {
|
|||||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||||
private readonly runtime: WorkspaceRuntime
|
private readonly runtime: WorkspaceRuntime
|
||||||
private readonly opencodeConfigDir: string
|
private readonly opencodeConfigDir: string
|
||||||
|
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||||
|
|
||||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||||
@@ -47,6 +55,10 @@ export class WorkspaceManager {
|
|||||||
return this.workspaces.get(id)?.port
|
return this.workspaces.get(id)?.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInstanceAuthorizationHeader(id: string): string | undefined {
|
||||||
|
return this.opencodeAuth.get(id)?.authorization
|
||||||
|
}
|
||||||
|
|
||||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
@@ -106,11 +118,22 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const preferences = this.options.configStore.get().preferences ?? {}
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const userEnvironment = preferences.environmentVariables ?? {}
|
||||||
|
|
||||||
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
|
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
|
||||||
|
if (!authorization) {
|
||||||
|
throw new Error("Failed to build OpenCode auth header")
|
||||||
|
}
|
||||||
|
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
|
||||||
|
|
||||||
const environment = {
|
const environment = {
|
||||||
...userEnvironment,
|
...userEnvironment,
|
||||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||||
CODENOMAD_INSTANCE_ID: id,
|
CODENOMAD_INSTANCE_ID: id,
|
||||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||||
|
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||||
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +177,7 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.workspaces.delete(id)
|
this.workspaces.delete(id)
|
||||||
|
this.opencodeAuth.delete(id)
|
||||||
clearWorkspaceSearchCache(workspace.path)
|
clearWorkspaceSearchCache(workspace.path)
|
||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||||
@@ -163,17 +187,29 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
this.options.logger.info("Shutting down all workspaces")
|
this.options.logger.info("Shutting down all workspaces")
|
||||||
|
|
||||||
|
const stopTasks: Array<Promise<void>> = []
|
||||||
|
|
||||||
for (const [id, workspace] of this.workspaces) {
|
for (const [id, workspace] of this.workspaces) {
|
||||||
if (workspace.pid) {
|
if (!workspace.pid) {
|
||||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
|
||||||
await this.runtime.stop(id).catch((error) => {
|
|
||||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||||
|
stopTasks.push(
|
||||||
|
this.runtime.stop(id).catch((error) => {
|
||||||
|
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stopTasks.length > 0) {
|
||||||
|
await Promise.allSettled(stopTasks)
|
||||||
|
}
|
||||||
|
|
||||||
this.workspaces.clear()
|
this.workspaces.clear()
|
||||||
|
this.opencodeAuth.clear()
|
||||||
this.options.logger.info("All workspaces cleared")
|
this.options.logger.info("All workspaces cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +236,15 @@ export class WorkspaceManager {
|
|||||||
try {
|
try {
|
||||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||||
if (result.status === 0 && result.stdout) {
|
if (result.status === 0 && result.stdout) {
|
||||||
const resolved = result.stdout
|
const candidates = result.stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.find((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
|
.filter((line) => !/^INFO:/i.test(line))
|
||||||
|
|
||||||
if (resolved) {
|
if (candidates.length > 0) {
|
||||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
const resolved = this.pickBinaryCandidate(candidates)
|
||||||
|
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
@@ -219,6 +257,23 @@ export class WorkspaceManager {
|
|||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickBinaryCandidate(candidates: string[]): string {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return candidates[0] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
||||||
|
|
||||||
|
for (const ext of extensionPreference) {
|
||||||
|
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
||||||
|
if (match) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||||
if (!resolvedPath) {
|
if (!resolvedPath) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -317,7 +372,13 @@ export class WorkspaceManager {
|
|||||||
const url = `http://127.0.0.1:${port}/project/current`
|
const url = `http://127.0.0.1:${port}/project/current`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url)
|
const headers: Record<string, string> = {}
|
||||||
|
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `health probe returned HTTP ${response.status}`
|
const reason = `health probe returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
@@ -408,6 +469,8 @@ export class WorkspaceManager {
|
|||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|
||||||
|
this.opencodeAuth.delete(workspaceId)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
|
||||||
|
|
||||||
workspace.pid = undefined
|
workspace.pid = undefined
|
||||||
|
|||||||
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
22
packages/server/src/workspaces/opencode-auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
|
||||||
|
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
|
||||||
|
|
||||||
|
export function generateOpencodeServerPassword(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
|
||||||
|
const username = params.username
|
||||||
|
const password = params.password
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
||||||
|
return `Basic ${token}`
|
||||||
|
}
|
||||||
@@ -1,10 +1,59 @@
|
|||||||
import { ChildProcess, spawn } from "child_process"
|
import { ChildProcess, spawn, spawnSync } from "child_process"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||||
import { Logger } from "../logger"
|
import { Logger } from "../logger"
|
||||||
|
|
||||||
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
|
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return { command: binaryPath, args, options: {} as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(binaryPath).toLowerCase()
|
||||||
|
|
||||||
|
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||||
|
const comspec = process.env.ComSpec || "cmd.exe"
|
||||||
|
// cmd.exe requires the full command as a single string.
|
||||||
|
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||||
|
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: comspec,
|
||||||
|
args: ["/d", "/s", "/c", commandLine],
|
||||||
|
options: { windowsVerbatimArguments: true } as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||||
|
// powershell.exe ships with Windows. (pwsh may not.)
|
||||||
|
return {
|
||||||
|
command: "powershell.exe",
|
||||||
|
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||||
|
options: {} as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: binaryPath, args, options: {} as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
|
||||||
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
|
const redacted: Record<string, string | undefined> = {}
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
redacted[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
interface LaunchOptions {
|
interface LaunchOptions {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
folder: string
|
folder: string
|
||||||
@@ -59,22 +108,27 @@ export class WorkspaceRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||||
|
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
folder: options.folder,
|
folder: options.folder,
|
||||||
binary: options.binaryPath,
|
binary: options.binaryPath,
|
||||||
args,
|
spawnCommand: spec.command,
|
||||||
|
spawnArgs: spec.args,
|
||||||
commandLine,
|
commandLine,
|
||||||
env,
|
env: redactEnvironment(env),
|
||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
const child = spawn(options.binaryPath, args, {
|
const detached = process.platform !== "win32"
|
||||||
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached,
|
||||||
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
const managed: ManagedProcess = { child, requestedStop: false }
|
const managed: ManagedProcess = { child, requestedStop: false }
|
||||||
@@ -207,10 +261,96 @@ export class WorkspaceRuntime {
|
|||||||
const child = managed.child
|
const child = managed.child
|
||||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||||
|
|
||||||
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
|
try {
|
||||||
|
// Negative PID targets the process group (POSIX).
|
||||||
|
process.kill(-pid, signal)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException
|
||||||
|
if (err?.code === "ESRCH") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException
|
||||||
|
if (err?.code === "ESRCH") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryTaskkill = (force: boolean) => {
|
||||||
|
const args = ["/PID", String(pid), "/T"]
|
||||||
|
if (force) {
|
||||||
|
args.push("/F")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||||
|
const exitCode = result.status
|
||||||
|
if (exitCode === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// If the PID is already gone, treat it as success.
|
||||||
|
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||||
|
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||||
|
const combined = `${stdout}\n${stderr}`
|
||||||
|
if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Best-effort: terminate the whole process tree rooted at pid.
|
||||||
|
// Use /F only for escalation.
|
||||||
|
tryTaskkill(signal === "SIGKILL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
|
||||||
|
const groupOk = tryKillPosixGroup(signal)
|
||||||
|
if (!groupOk) {
|
||||||
|
// Fallback to direct PID kill.
|
||||||
|
tryKillSinglePid(signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let escalationTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
child.removeListener("exit", onExit)
|
child.removeListener("exit", onExit)
|
||||||
child.removeListener("error", onError)
|
child.removeListener("error", onError)
|
||||||
|
if (escalationTimer) {
|
||||||
|
clearTimeout(escalationTimer)
|
||||||
|
escalationTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onExit = () => {
|
const onExit = () => {
|
||||||
@@ -222,32 +362,30 @@ export class WorkspaceRuntime {
|
|||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIfAlreadyExited = () => {
|
if (isAlreadyExited()) {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
cleanup()
|
||||||
cleanup()
|
resolve()
|
||||||
resolve()
|
return
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
child.once("exit", onExit)
|
child.once("exit", onExit)
|
||||||
child.once("error", onError)
|
child.once("error", onError)
|
||||||
|
|
||||||
if (resolveIfAlreadyExited()) {
|
this.logger.debug(
|
||||||
return
|
{ workspaceId, pid, detached: process.platform !== "win32" },
|
||||||
}
|
"Sending SIGTERM to workspace process (tree/group)",
|
||||||
|
)
|
||||||
|
sendStopSignal("SIGTERM")
|
||||||
|
|
||||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
escalationTimer = setTimeout(() => {
|
||||||
child.kill("SIGTERM")
|
escalationTimer = null
|
||||||
setTimeout(() => {
|
if (isAlreadyExited()) {
|
||||||
if (!child.killed) {
|
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
|
||||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
return
|
||||||
child.kill("SIGKILL")
|
|
||||||
} else {
|
|
||||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
|
||||||
}
|
}
|
||||||
|
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
|
||||||
|
sendStopSignal("SIGKILL")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
"dev": "tauri dev",
|
||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
"dev:prep": "node ./scripts/dev-prep.js",
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
|
|||||||
@@ -166,6 +166,44 @@ function copyServerArtifacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = path.join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyUiLoadingAssets() {
|
function copyUiLoadingAssets() {
|
||||||
const loadingSource = path.join(uiDist, "loading.html")
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
const assetsSource = path.join(uiDist, "assets")
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
@@ -192,4 +230,5 @@ ensureServerDependencies()
|
|||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ use std::collections::VecDeque;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
@@ -31,9 +32,17 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
log_line(&format!("navigating main to {url}"));
|
let mut display = url.to_string();
|
||||||
|
if let Some(hash_index) = display.find('#') {
|
||||||
|
display.replace_range(hash_index + 1.., "[REDACTED]");
|
||||||
|
}
|
||||||
|
log_line(&format!("navigating main to {display}"));
|
||||||
if let Ok(parsed) = Url::parse(url) {
|
if let Ok(parsed) = Url::parse(url) {
|
||||||
let _ = win.navigate(parsed);
|
let _ = win.navigate(parsed);
|
||||||
} else {
|
} else {
|
||||||
@@ -44,6 +53,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{name}=");
|
||||||
|
let cookie_kv = set_cookie.split(';').next()?.trim();
|
||||||
|
if !cookie_kv.starts_with(&prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = cookie_kv.trim_start_matches(&prefix).trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
|
|
||||||
|
// This is only used for local bootstrap; we assume plain HTTP.
|
||||||
|
let mut stream = TcpStream::connect((host, port))?;
|
||||||
|
|
||||||
|
let body = format!("{{\"token\":\"{}\"}}", token);
|
||||||
|
let request = format!(
|
||||||
|
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.as_bytes().len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
stream.write_all(request.as_bytes())?;
|
||||||
|
stream.flush()?;
|
||||||
|
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response)?;
|
||||||
|
|
||||||
|
let (raw_headers, _rest) = response
|
||||||
|
.split_once("\r\n\r\n")
|
||||||
|
.or_else(|| response.split_once("\n\n"))
|
||||||
|
.unwrap_or((response.as_str(), ""));
|
||||||
|
|
||||||
|
let mut lines = raw_headers.lines();
|
||||||
|
let status_line = lines.next().unwrap_or("");
|
||||||
|
if !status_line.contains(" 200 ") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
// handle case-insensitive header name
|
||||||
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
|
return Ok(Some(session_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||||
|
let parsed = Url::parse(base_url)?;
|
||||||
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
|
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||||
|
.domain(domain)
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(tauri::webview::cookie::SameSite::Lax)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
|
win.set_cookie(cookie)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -139,6 +227,7 @@ pub struct CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliProcessManager {
|
impl CliProcessManager {
|
||||||
@@ -147,6 +236,7 @@ impl CliProcessManager {
|
|||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +244,7 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("start requested (dev={dev})"));
|
log_line(&format!("start requested (dev={dev})"));
|
||||||
self.stop()?;
|
self.stop()?;
|
||||||
self.ready.store(false, Ordering::SeqCst);
|
self.ready.store(false, Ordering::SeqCst);
|
||||||
|
*self.bootstrap_token.lock() = None;
|
||||||
{
|
{
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
status.state = CliState::Starting;
|
status.state = CliState::Starting;
|
||||||
@@ -167,8 +258,9 @@ impl CliProcessManager {
|
|||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
|
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
||||||
log_line(&format!("cli spawn failed: {err}"));
|
log_line(&format!("cli spawn failed: {err}"));
|
||||||
let mut locked = status_arc.lock();
|
let mut locked = status_arc.lock();
|
||||||
locked.state = CliState::Error;
|
locked.state = CliState::Error;
|
||||||
@@ -186,6 +278,7 @@ impl CliProcessManager {
|
|||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||||
@@ -200,7 +293,12 @@ impl CliProcessManager {
|
|||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
if start.elapsed() > Duration::from_secs(4) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
|
log_line(&format!(
|
||||||
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||||
|
CLI_STOP_GRACE_SECS,
|
||||||
|
child.id()
|
||||||
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||||
@@ -237,6 +335,7 @@ impl CliProcessManager {
|
|||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
log_line("resolving CLI entry");
|
log_line("resolving CLI entry");
|
||||||
@@ -318,8 +417,10 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
|
let token_clone = bootstrap_token.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
.lock()
|
.lock()
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -332,10 +433,10 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
|
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,10 +508,12 @@ impl CliProcessManager {
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
|
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();
|
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
|
||||||
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
@@ -419,6 +522,17 @@ impl CliProcessManager {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let line = buffer.trim_end();
|
let line = buffer.trim_end();
|
||||||
if !line.is_empty() {
|
if !line.is_empty() {
|
||||||
|
if line.starts_with(token_prefix) {
|
||||||
|
let token = line.trim_start_matches(token_prefix).trim();
|
||||||
|
if !token.is_empty() {
|
||||||
|
let mut guard = bootstrap_token.lock();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
log_line(&format!("[cli][{}] {}", stream, line));
|
log_line(&format!("[cli][{}] {}", stream, line));
|
||||||
|
|
||||||
if ready.load(Ordering::SeqCst) {
|
if ready.load(Ordering::SeqCst) {
|
||||||
@@ -430,7 +544,7 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,13 +554,13 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, port);
|
Self::mark_ready(app, status, ready, bootstrap_token, port);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
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()) {
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
Self::mark_ready(app, status, ready, port as u16);
|
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,16 +572,46 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
|
fn mark_ready(
|
||||||
|
app: &AppHandle,
|
||||||
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
|
ready: &Arc<AtomicBool>,
|
||||||
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
|
port: u16,
|
||||||
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
|
let base_url = format!("http://127.0.0.1:{port}");
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
let url = format!("http://127.0.0.1:{port}");
|
|
||||||
locked.port = Some(port);
|
locked.port = Some(port);
|
||||||
locked.url = Some(url.clone());
|
locked.url = Some(base_url.clone());
|
||||||
locked.state = CliState::Ready;
|
locked.state = CliState::Ready;
|
||||||
locked.error = None;
|
locked.error = None;
|
||||||
log_line(&format!("cli ready on {url}"));
|
log_line(&format!("cli ready on {base_url}"));
|
||||||
navigate_main(app, &url);
|
|
||||||
|
let token = bootstrap_token.lock().take();
|
||||||
|
|
||||||
|
if let Some(token) = token {
|
||||||
|
match exchange_bootstrap_token(&base_url, &token) {
|
||||||
|
Ok(Some(session_id)) => {
|
||||||
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||||
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log_line("bootstrap token exchange failed (invalid token)");
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!("bootstrap token exchange failed: {err}"));
|
||||||
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate_main(app, &base_url);
|
||||||
|
}
|
||||||
let _ = app.emit("cli:ready", locked.clone());
|
let _ = app.emit("cli:ready", locked.clone());
|
||||||
Self::emit_status(app, &locked);
|
Self::emit_status(app, &locked);
|
||||||
}
|
}
|
||||||
@@ -551,6 +695,7 @@ impl CliEntry {
|
|||||||
host.to_string(),
|
host.to_string(),
|
||||||
"--port".to_string(),
|
"--port".to_string(),
|
||||||
"0".to_string(),
|
"0".to_string(),
|
||||||
|
"--generate-token".to_string(),
|
||||||
];
|
];
|
||||||
if dev {
|
if dev {
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ fn main() {
|
|||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app_handle, event| match event {
|
.run(|app_handle, event| match event {
|
||||||
tauri::RunEvent::ExitRequested { .. } => {
|
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||||
|
api.prevent_exit();
|
||||||
let app = app_handle.clone();
|
let app = app_handle.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
@@ -173,18 +174,18 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::Destroyed,
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if app_handle.webview_windows().len() <= 1 {
|
// Ensure we have time to stop the CLI process before the app exits.
|
||||||
let app = app_handle.clone();
|
api.prevent_close();
|
||||||
std::thread::spawn(move || {
|
let app = app_handle.clone();
|
||||||
if let Some(state) = app.try_state::<AppState>() {
|
std::thread::spawn(move || {
|
||||||
let _ = state.manager.stop();
|
if let Some(state) = app.try_state::<AppState>() {
|
||||||
}
|
let _ = state.manager.stop();
|
||||||
app.exit(0);
|
}
|
||||||
});
|
app.exit(0);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.5.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.1",
|
"@opencode-ai/sdk": "1.1.11",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
|||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
import { useTheme } from "./lib/theme"
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
@@ -17,6 +18,7 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
|||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
|
import { useI18n } from "./lib/i18n"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -50,6 +52,7 @@ const log = getLogger("actions")
|
|||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
@@ -94,6 +97,7 @@ const App: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
void initGithubStars()
|
||||||
updateInstanceTabBarHeight()
|
updateInstanceTabBarHeight()
|
||||||
const handleResize = () => updateInstanceTabBarHeight()
|
const handleResize = () => updateInstanceTabBarHeight()
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
@@ -117,7 +121,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return "Failed to launch workspace"
|
return t("app.launchError.fallbackMessage")
|
||||||
}
|
}
|
||||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||||
try {
|
try {
|
||||||
@@ -200,12 +204,12 @@ const App: Component = () => {
|
|||||||
|
|
||||||
async function handleCloseInstance(instanceId: string) {
|
async function handleCloseInstance(instanceId: string) {
|
||||||
const confirmed = await showConfirmDialog(
|
const confirmed = await showConfirmDialog(
|
||||||
"Stop OpenCode instance? This will stop the server.",
|
t("app.stopInstance.confirmMessage"),
|
||||||
{
|
{
|
||||||
title: "Stop instance",
|
title: t("app.stopInstance.title"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: "Stop",
|
confirmLabel: t("app.stopInstance.confirmLabel"),
|
||||||
cancelLabel: "Keep running",
|
cancelLabel: t("app.stopInstance.cancelLabel"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -328,21 +332,20 @@ const App: Component = () => {
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<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
|
{t("app.launchError.description")}
|
||||||
binary from Advanced Settings.
|
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<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">Binary path</p>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={launchErrorMessage()}>
|
<Show when={launchErrorMessage()}>
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<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>
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
|
||||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -354,11 +357,11 @@ const App: Component = () => {
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={handleLaunchErrorAdvanced}
|
onClick={handleLaunchErrorAdvanced}
|
||||||
>
|
>
|
||||||
Open Advanced Settings
|
{t("app.launchError.openAdvancedSettings")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||||
Close
|
{t("app.launchError.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
@@ -428,7 +431,7 @@ const App: Component = () => {
|
|||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
}}
|
}}
|
||||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
title="Close (Esc)"
|
title={t("app.launchError.closeTitle")}
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
import OpenCodeBinarySelector from "./opencode-binary-selector"
|
||||||
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
import EnvironmentVariablesEditor from "./environment-variables-editor"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface AdvancedSettingsModalProps {
|
interface AdvancedSettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3 class="panel-title">Environment Variables</h3>
|
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||||
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
|
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
|
||||||
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
Close
|
{t("advancedSettings.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import Kbd from "./kbd"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ interface AgentSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentSelector(props: AgentSelectorProps) {
|
export default function AgentSelector(props: AgentSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||||
|
|
||||||
const session = createMemo(() => {
|
const session = createMemo(() => {
|
||||||
@@ -71,7 +74,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
options={availableAgents()}
|
options={availableAgents()}
|
||||||
optionValue="name"
|
optionValue="name"
|
||||||
optionTextValue="name"
|
optionTextValue="name"
|
||||||
placeholder="Select agent..."
|
placeholder={t("agentSelector.placeholder")}
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => (
|
||||||
<Select.Item
|
<Select.Item
|
||||||
item={itemProps.item}
|
item={itemProps.item}
|
||||||
@@ -81,7 +84,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
|
||||||
<span>{itemProps.item.rawValue.name}</span>
|
<span>{itemProps.item.rawValue.name}</span>
|
||||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
||||||
<span class="neutral-badge">subagent</span>
|
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</Select.ItemLabel>
|
</Select.ItemLabel>
|
||||||
<Show when={itemProps.item.rawValue.description}>
|
<Show when={itemProps.item.rawValue.description}>
|
||||||
@@ -99,15 +102,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
data-agent-selector
|
data-agent-selector
|
||||||
class="selector-trigger"
|
class="selector-trigger"
|
||||||
>
|
>
|
||||||
<Select.Value<Agent>>
|
<div class="flex-1 min-w-0">
|
||||||
{(state) => (
|
<Select.Value<Agent>>
|
||||||
<div class="selector-trigger-label">
|
{(state) => (
|
||||||
<span class="selector-trigger-primary">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
Agent: {state.selectedOption()?.name ?? "None"}
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
</span>
|
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</Select.Value>
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||||
|
<Kbd shortcut="cmd+shift+a" />
|
||||||
|
</span>
|
||||||
<Select.Icon class="selector-trigger-icon">
|
<Select.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Select.Icon>
|
</Select.Icon>
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Component, Show, createEffect } from "solid-js"
|
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||||
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
|
||||||
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
|
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
|
||||||
info: {
|
info: {
|
||||||
badgeBg: "var(--badge-neutral-bg)",
|
badgeBg: "var(--badge-neutral-bg)",
|
||||||
badgeBorder: "var(--border-base)",
|
badgeBorder: "var(--border-base)",
|
||||||
badgeText: "var(--accent-primary)",
|
badgeText: "var(--accent-primary)",
|
||||||
symbol: "i",
|
symbol: "i",
|
||||||
fallbackTitle: "Heads up",
|
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
badgeBg: "rgba(255, 152, 0, 0.14)",
|
badgeBg: "rgba(255, 152, 0, 0.14)",
|
||||||
badgeBorder: "var(--status-warning)",
|
badgeBorder: "var(--status-warning)",
|
||||||
badgeText: "var(--status-warning)",
|
badgeText: "var(--status-warning)",
|
||||||
symbol: "!",
|
symbol: "!",
|
||||||
fallbackTitle: "Please review",
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
badgeBg: "var(--danger-soft-bg)",
|
badgeBg: "var(--danger-soft-bg)",
|
||||||
badgeBorder: "var(--status-error)",
|
badgeBorder: "var(--status-error)",
|
||||||
badgeText: "var(--status-error)",
|
badgeText: "var(--status-error)",
|
||||||
symbol: "!",
|
symbol: "!",
|
||||||
fallbackTitle: "Something went wrong",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
|
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
|
||||||
const current = payload ?? alertDialogState()
|
const current = payload ?? alertDialogState()
|
||||||
|
|
||||||
if (current?.type === "confirm") {
|
if (current?.type === "confirm") {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
current.onConfirm?.()
|
current.onConfirm?.()
|
||||||
@@ -36,21 +35,45 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
|
|||||||
current.onCancel?.()
|
current.onCancel?.()
|
||||||
}
|
}
|
||||||
current.resolve?.(confirmed)
|
current.resolve?.(confirmed)
|
||||||
} else if (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?.()
|
current?.onConfirm?.()
|
||||||
}
|
}
|
||||||
dismissAlertDialog()
|
dismissAlertDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlertDialog: Component = () => {
|
const AlertDialog: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
let primaryButtonRef: HTMLButtonElement | undefined
|
let primaryButtonRef: HTMLButtonElement | undefined
|
||||||
|
let promptInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (alertDialogState()) {
|
const state = alertDialogState()
|
||||||
queueMicrotask(() => {
|
if (!state) return
|
||||||
primaryButtonRef?.focus()
|
|
||||||
})
|
queueMicrotask(() => {
|
||||||
}
|
if (state.type === "prompt") {
|
||||||
|
promptInputRef?.focus()
|
||||||
|
promptInputRef?.select()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
primaryButtonRef?.focus()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,10 +81,27 @@ const AlertDialog: Component = () => {
|
|||||||
{(payload) => {
|
{(payload) => {
|
||||||
const variant = payload.variant ?? "info"
|
const variant = payload.variant ?? "info"
|
||||||
const accent = variantAccent[variant]
|
const accent = variantAccent[variant]
|
||||||
const title = payload.title || accent.fallbackTitle
|
|
||||||
|
const fallbackTitle =
|
||||||
|
variant === "warning"
|
||||||
|
? t("alertDialog.fallbackTitle.warning")
|
||||||
|
: variant === "error"
|
||||||
|
? t("alertDialog.fallbackTitle.error")
|
||||||
|
: t("alertDialog.fallbackTitle.info")
|
||||||
|
|
||||||
|
const title = payload.title || fallbackTitle
|
||||||
const isConfirm = payload.type === "confirm"
|
const isConfirm = payload.type === "confirm"
|
||||||
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
|
const isPrompt = payload.type === "prompt"
|
||||||
const cancelLabel = payload.cancelLabel || "Cancel"
|
const confirmLabel =
|
||||||
|
payload.confirmLabel ||
|
||||||
|
(isConfirm
|
||||||
|
? t("alertDialog.actions.confirm")
|
||||||
|
: isPrompt
|
||||||
|
? t("alertDialog.actions.run")
|
||||||
|
: t("alertDialog.actions.ok"))
|
||||||
|
const cancelLabel = payload.cancelLabel || t("alertDialog.actions.cancel")
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -98,27 +138,53 @@ const AlertDialog: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<Show when={isPrompt}>
|
||||||
{isConfirm && (
|
<div class="mt-4">
|
||||||
<button
|
<label class="text-sm font-medium text-secondary">
|
||||||
type="button"
|
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||||
class="button-secondary"
|
</label>
|
||||||
onClick={() => dismiss(false, payload)}
|
<input
|
||||||
>
|
ref={(el) => {
|
||||||
{cancelLabel}
|
promptInputRef = el
|
||||||
</button>
|
}}
|
||||||
)}
|
class="form-input mt-2"
|
||||||
<button
|
value={inputValue()}
|
||||||
type="button"
|
placeholder={payload.inputPlaceholder || ""}
|
||||||
class="button-primary"
|
autocapitalize="off"
|
||||||
ref={(el) => {
|
autocorrect="off"
|
||||||
primaryButtonRef = el
|
spellcheck={false}
|
||||||
}}
|
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||||
onClick={() => dismiss(true, payload)}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === "Enter") {
|
||||||
{confirmLabel}
|
e.preventDefault()
|
||||||
</button>
|
dismiss(true, payload, inputValue())
|
||||||
</div>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from "solid-js"
|
import { Component } from "solid-js"
|
||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment } from "../types/attachment"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface AttachmentChipProps {
|
interface AttachmentChipProps {
|
||||||
attachment: Attachment
|
attachment: Attachment
|
||||||
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="attachment-chip"
|
class="attachment-chip"
|
||||||
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
onClick={props.onRemove}
|
onClick={props.onRemove}
|
||||||
class="attachment-remove"
|
class="attachment-remove"
|
||||||
aria-label="Remove attachment"
|
aria-label={t("attachmentChip.removeAriaLabel")}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
|||||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||||
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface BackgroundProcessOutputDialogProps {
|
interface BackgroundProcessOutputDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -12,6 +13,7 @@ interface BackgroundProcessOutputDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [output, setOutput] = createSignal("")
|
const [output, setOutput] = createSignal("")
|
||||||
const [outputHtml, setOutputHtml] = createSignal("")
|
const [outputHtml, setOutputHtml] = createSignal("")
|
||||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||||
@@ -67,7 +69,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setRawOutput("Failed to load output.")
|
setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
|
||||||
setAnsiEnabled(false)
|
setAnsiEnabled(false)
|
||||||
setOutputHtml("")
|
setOutputHtml("")
|
||||||
})
|
})
|
||||||
@@ -76,7 +78,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||||
@@ -121,7 +123,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
<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 items-start justify-between px-6 py-4 border-b border-base gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
|
<Dialog.Title class="text-lg font-semibold text-primary">{t("backgroundProcessOutputDialog.title")}</Dialog.Title>
|
||||||
<Show when={props.process}>
|
<Show when={props.process}>
|
||||||
<span class="text-xs text-secondary block">
|
<span class="text-xs text-secondary block">
|
||||||
{props.process?.title} · {props.process?.id}
|
{props.process?.title} · {props.process?.id}
|
||||||
@@ -133,16 +135,16 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
||||||
Close
|
{t("backgroundProcessOutputDialog.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-auto p-6">
|
<div class="flex-1 overflow-auto p-6">
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<p class="text-xs text-secondary">Loading output...</p>
|
<p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!loading()}>
|
<Show when={!loading()}>
|
||||||
<Show when={truncated()}>
|
<Show when={truncated()}>
|
||||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
<p class="text-xs text-secondary mb-2">{t("backgroundProcessOutputDialog.truncatedNotice")}</p>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={ansiEnabled()}
|
when={ansiEnabled()}
|
||||||
|
|||||||
38
packages/ui/src/components/brand-icons.tsx
Normal file
38
packages/ui/src/components/brand-icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
|
type BrandIconProps = {
|
||||||
|
class?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 98 96"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden={props.title ? undefined : "true"}
|
||||||
|
role={props.title ? "img" : "presentation"}
|
||||||
|
class={props.class}
|
||||||
|
>
|
||||||
|
{props.title ? <title>{props.title}</title> : null}
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 64 48"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden={props.title ? undefined : "true"}
|
||||||
|
role={props.title ? "img" : "presentation"}
|
||||||
|
class={props.class}
|
||||||
|
>
|
||||||
|
{props.title ? <title>{props.title}</title> : null}
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ import type { Highlighter } from "shiki/bundle/full"
|
|||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const inlineLoadedLanguages = new Set<string>()
|
const inlineLoadedLanguages = new Set<string>()
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface CodeBlockInlineProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -97,8 +99,8 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
|||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="copy-text">
|
<span class="copy-text">
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
|
||||||
Copied!
|
{t("codeBlockInline.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import type { Command } from "../lib/commands"
|
import { resolveResolvable, type Command } from "../lib/commands"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
|
||||||
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
|
||||||
@@ -32,6 +34,27 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||||
|
|
||||||
|
const categoryLabel = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case "Custom Commands":
|
||||||
|
return t("commandPalette.category.customCommands")
|
||||||
|
case "Instance":
|
||||||
|
return t("commandPalette.category.instance")
|
||||||
|
case "Session":
|
||||||
|
return t("commandPalette.category.session")
|
||||||
|
case "Agent & Model":
|
||||||
|
return t("commandPalette.category.agentModel")
|
||||||
|
case "Input & Focus":
|
||||||
|
return t("commandPalette.category.inputFocus")
|
||||||
|
case "System":
|
||||||
|
return t("commandPalette.category.system")
|
||||||
|
case "Other":
|
||||||
|
return t("commandPalette.category.other")
|
||||||
|
default:
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||||
|
|
||||||
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const filtered = q
|
const filtered = q
|
||||||
? source.filter((cmd) => {
|
? source.filter((cmd) => {
|
||||||
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
|
const label = resolveResolvable(cmd.label)
|
||||||
|
const description = resolveResolvable(cmd.description)
|
||||||
|
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
|
||||||
|
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
|
||||||
const labelMatch = label.toLowerCase().includes(q)
|
const labelMatch = label.toLowerCase().includes(q)
|
||||||
const descMatch = cmd.description.toLowerCase().includes(q)
|
const descMatch = description.toLowerCase().includes(q)
|
||||||
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
|
||||||
const categoryMatch = cmd.category?.toLowerCase().includes(q)
|
const categoryMatch = category?.toLowerCase().includes(q)
|
||||||
return labelMatch || descMatch || keywordMatch || categoryMatch
|
return labelMatch || descMatch || keywordMatch || categoryMatch
|
||||||
})
|
})
|
||||||
: source
|
: source
|
||||||
|
|
||||||
const groupsMap = new Map<string, Command[]>()
|
const groupsMap = new Map<string, Command[]>()
|
||||||
for (const cmd of filtered) {
|
for (const cmd of filtered) {
|
||||||
const category = cmd.category || "Other"
|
const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
|
||||||
const list = groupsMap.get(category)
|
const list = groupsMap.get(category)
|
||||||
if (list) {
|
if (list) {
|
||||||
list.push(cmd)
|
list.push(cmd)
|
||||||
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
class="modal-surface w-full max-w-2xl max-h-[60vh]"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
|
<Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
|
<Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
|
||||||
|
|
||||||
<div class="modal-search-container">
|
<div class="modal-search-container">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
setQuery(e.currentTarget.value)
|
setQuery(e.currentTarget.value)
|
||||||
setSelectedCommandId(null)
|
setSelectedCommandId(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Type a command or search..."
|
placeholder={t("commandPalette.searchPlaceholder")}
|
||||||
class="modal-search-input"
|
class="modal-search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={orderedCommands().length > 0}
|
when={orderedCommands().length > 0}
|
||||||
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
|
fallback={<div class="modal-empty-state">{t("commandPalette.empty", { query: query() })}</div>}
|
||||||
>
|
>
|
||||||
<For each={groupedCommandList()}>
|
<For each={groupedCommandList()}>
|
||||||
{(group) => (
|
{(group) => (
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<div class="modal-section-header">
|
<div class="modal-section-header">
|
||||||
{group.category}
|
{categoryLabel(group.category)}
|
||||||
</div>
|
</div>
|
||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="modal-item-label">
|
<div class="modal-item-label">
|
||||||
{typeof command.label === "function" ? command.label() : command.label}
|
{resolveResolvable(command.label)}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-item-description">
|
<div class="modal-item-description">
|
||||||
{command.description}
|
{resolveResolvable(command.description)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={command.shortcut}>
|
<Show when={command.shortcut}>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
|
||||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
function normalizePathKey(input?: string | null) {
|
function normalizePathKey(input?: string | null) {
|
||||||
if (!input || input === "." || input === "./") {
|
if (!input || input === "." || input === "./") {
|
||||||
@@ -61,9 +63,11 @@ type FolderRow =
|
|||||||
| { type: "folder"; entry: FileSystemEntry }
|
| { type: "folder"; entry: FileSystemEntry }
|
||||||
|
|
||||||
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||||
@@ -108,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const metadata = await loadDirectory()
|
const metadata = await loadDirectory()
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||||
setError(message)
|
setError(message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -198,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const metadata = await loadDirectory(path)
|
const metadata = await loadDirectory(path)
|
||||||
applyMetadata(metadata)
|
applyMetadata(metadata)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||||
setError(message)
|
setError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +260,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
props.onSelect(absolutePath)
|
props.onSelect(absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateFolder() {
|
||||||
|
if (creatingFolder()) return
|
||||||
|
const metadata = currentMetadata()
|
||||||
|
if (!metadata || metadata.pathKind === "drives") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||||
|
title: t("directoryBrowser.createFolder.title"),
|
||||||
|
inputLabel: t("directoryBrowser.createFolder.inputLabel"),
|
||||||
|
inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
|
||||||
|
confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
|
||||||
|
cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
|
||||||
|
}))?.trim() ?? ""
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
||||||
|
showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
|
||||||
|
variant: "warning",
|
||||||
|
detail: t("directoryBrowser.createFolder.invalidNameDetail"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingFolder(true)
|
||||||
|
try {
|
||||||
|
const parentKey = normalizePathKey(metadata.currentPath)
|
||||||
|
metadataCache.delete(parentKey)
|
||||||
|
inFlightRequests.delete(parentKey)
|
||||||
|
setDirectoryChildren((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(parentKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
||||||
|
await navigateTo(created.path)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
|
||||||
|
showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
|
||||||
|
} finally {
|
||||||
|
setCreatingFolder(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPathLoading(path: string) {
|
function isPathLoading(path: string) {
|
||||||
return loadingPaths().has(normalizePathKey(path))
|
return loadingPaths().has(normalizePathKey(path))
|
||||||
}
|
}
|
||||||
@@ -275,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<div class="directory-browser-heading">
|
<div class="directory-browser-heading">
|
||||||
<h3 class="directory-browser-title">{props.title}</h3>
|
<h3 class="directory-browser-title">{props.title}</h3>
|
||||||
<p class="directory-browser-description">
|
<p class="directory-browser-description">
|
||||||
{props.description || "Browse folders under the configured workspace root."}
|
{props.description || t("directoryBrowser.defaultDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
|
<button type="button" class="directory-browser-close" aria-label={t("directoryBrowser.close")} onClick={props.onClose}>
|
||||||
<X class="w-5 h-5" />
|
<X class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,22 +337,35 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<Show when={rootPath()}>
|
<Show when={rootPath()}>
|
||||||
<div class="directory-browser-current">
|
<div class="directory-browser-current">
|
||||||
<div class="directory-browser-current-meta">
|
<div class="directory-browser-current-meta">
|
||||||
<span class="directory-browser-current-label">Current folder</span>
|
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="directory-browser-current-actions">
|
||||||
type="button"
|
<button
|
||||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
type="button"
|
||||||
disabled={!canSelectCurrent()}
|
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||||
onClick={() => {
|
disabled={!canSelectCurrent() || creatingFolder()}
|
||||||
const absolute = currentAbsolutePath()
|
onClick={() => {
|
||||||
if (absolute) {
|
const absolute = currentAbsolutePath()
|
||||||
props.onSelect(absolute)
|
if (absolute) {
|
||||||
}
|
props.onSelect(absolute)
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
Select Current
|
>
|
||||||
</button>
|
{t("directoryBrowser.selectCurrent")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary directory-browser-select"
|
||||||
|
disabled={!canSelectCurrent() || creatingFolder()}
|
||||||
|
onClick={() => void handleCreateFolder()}
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<FolderPlus class="w-4 h-4" />
|
||||||
|
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
@@ -312,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
|
||||||
<div class="directory-browser-loading">
|
<div class="directory-browser-loading">
|
||||||
<Loader2 class="w-5 h-5 animate-spin" />
|
<Loader2 class="w-5 h-5 animate-spin" />
|
||||||
<span>Loading folders…</span>
|
<span>{t("directoryBrowser.loadingFolders")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={folderRows().length > 0}
|
when={folderRows().length > 0}
|
||||||
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
|
fallback={<div class="panel-empty-state flex-1">{t("directoryBrowser.noFolders")}</div>}
|
||||||
>
|
>
|
||||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
|
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
|
||||||
<For each={folderRows()}>
|
<For each={folderRows()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const isFolder = item.type === "folder"
|
const isFolder = item.type === "folder"
|
||||||
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
|
const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
|
||||||
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
|
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
|
||||||
return (
|
return (
|
||||||
<div class="panel-list-item" role="option">
|
<div class="panel-list-item" role="option">
|
||||||
@@ -353,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
handleEntrySelect(item.entry)
|
handleEntrySelect(item.entry)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select
|
{t("directoryBrowser.select")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from "solid-js"
|
import { Component } from "solid-js"
|
||||||
import { Loader2 } from "lucide-solid"
|
import { Loader2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -9,15 +10,19 @@ interface EmptyStateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState: Component<EmptyStateProps> = (props) => {
|
const EmptyState: Component<EmptyStateProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
|
||||||
|
const shortcut = `${modifier}+N`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
|
||||||
<div class="max-w-[500px] px-8 py-12 text-center">
|
<div class="max-w-[500px] px-8 py-12 text-center">
|
||||||
<div class="mb-8 flex justify-center">
|
<div class="mb-8 flex justify-center">
|
||||||
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
|
<img src={codeNomadIcon} alt={t("emptyState.logoAlt")} class="h-24 w-auto" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
|
||||||
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
|
||||||
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
|
|||||||
{props.isLoading ? (
|
{props.isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 class="h-4 w-4 animate-spin" />
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
Selecting...
|
{t("emptyState.actions.selecting")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Select Folder"
|
t("emptyState.actions.selectFolder")
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
|
{t("emptyState.keyboardShortcut", { shortcut })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 space-y-1 text-sm text-muted">
|
<div class="mt-6 space-y-1 text-sm text-muted">
|
||||||
<p>Examples: ~/projects/my-app</p>
|
<p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
|
||||||
<p>You can have multiple instances of the same folder</p>
|
<p>{t("emptyState.multipleInstances")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Component, createSignal, For, Show } from "solid-js"
|
import { Component, createSignal, For, Show } from "solid-js"
|
||||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface EnvironmentVariablesEditorProps {
|
interface EnvironmentVariablesEditorProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<Globe class="w-4 h-4 icon-muted" />
|
<Globe class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">Environment Variables</span>
|
<span class="text-sm font-medium text-secondary">{t("envEditor.title")}</span>
|
||||||
<span class="text-xs text-muted">
|
<span class="text-xs text-muted">
|
||||||
({entries().length} variable{entries().length !== 1 ? "s" : ""})
|
{entries().length === 1
|
||||||
|
? t("envEditor.count.one", { count: entries().length })
|
||||||
|
: t("envEditor.count.other", { count: entries().length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
value={key}
|
value={key}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
|
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
|
||||||
placeholder="Variable name"
|
placeholder={t("envEditor.fields.name.placeholder")}
|
||||||
title="Variable name (read-only)"
|
title={t("envEditor.fields.name.readOnlyTitle")}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
|
||||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
placeholder="Variable value"
|
placeholder={t("envEditor.fields.value.placeholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveVariable(key)}
|
onClick={() => handleRemoveVariable(key)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
title="Remove variable"
|
title={t("envEditor.actions.remove.title")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
placeholder="Variable name"
|
placeholder={t("envEditor.fields.name.placeholder")}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
placeholder="Variable value"
|
placeholder={t("envEditor.fields.value.placeholder")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddVariable}
|
onClick={handleAddVariable}
|
||||||
disabled={props.disabled || !newKey().trim()}
|
disabled={props.disabled || !newKey().trim()}
|
||||||
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
title="Add variable"
|
title={t("envEditor.actions.add.title")}
|
||||||
>
|
>
|
||||||
<Plus class="w-3.5 h-3.5" />
|
<Plus class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
|
|||||||
|
|
||||||
<Show when={entries().length === 0}>
|
<Show when={entries().length === 0}>
|
||||||
<div class="text-xs text-muted text-center py-2">
|
<div class="text-xs text-muted text-center py-2">
|
||||||
No environment variables configured. Add variables above to customize the OpenCode environment.
|
{t("envEditor.empty")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="text-xs text-muted mt-2">
|
<div class="text-xs text-muted mt-2">
|
||||||
These variables will be available in the OpenCode environment when starting instances.
|
{t("envEditor.help")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
33
packages/ui/src/components/expand-button.tsx
Normal file
33
packages/ui/src/components/expand-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
interface ExpandButtonProps {
|
||||||
|
expandState: () => "normal" | "expanded"
|
||||||
|
onToggleExpand: (nextState: "normal" | "expanded") => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpandButton(props: ExpandButtonProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
const current = props.expandState()
|
||||||
|
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-expand-button"
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label={t("expandButton.toggleAriaLabel")}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.expandState() === "normal"}
|
||||||
|
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<Maximize2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft
|
|||||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
|
|||||||
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||||
|
|
||||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||||
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
setRootPath(metadata.rootPath)
|
setRootPath(metadata.rootPath)
|
||||||
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
const message = err instanceof Error ? err.message : t("filesystemBrowser.errors.loadFilesystemFallback")
|
||||||
setError(message)
|
setError(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
function describeLoadingPath() {
|
function describeLoadingPath() {
|
||||||
const path = loadingPath()
|
const path = loadingPath()
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return "filesystem"
|
return t("filesystemBrowser.loading.filesystem")
|
||||||
}
|
}
|
||||||
if (path === ".") {
|
if (path === ".") {
|
||||||
return rootPath() || "workspace root"
|
return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
|
||||||
}
|
}
|
||||||
return resolveAbsolutePath(rootPath(), path)
|
return resolveAbsolutePath(rootPath(), path)
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
function handleNavigateTo(path: string) {
|
function handleNavigateTo(path: string) {
|
||||||
void fetchDirectory(path, true).catch((err) => {
|
void fetchDirectory(path, true).catch((err) => {
|
||||||
log.error("Failed to open directory", err)
|
log.error("Failed to open directory", err)
|
||||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
setError(err instanceof Error ? err.message : t("filesystemBrowser.errors.openDirectoryFallback"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,19 +279,21 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="panel-header flex items-start justify-between gap-4">
|
<div class="panel-header flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="panel-title">{props.title}</h3>
|
<h3 class="panel-title">{props.title}</h3>
|
||||||
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
|
<p class="panel-subtitle">{props.description || t("filesystemBrowser.descriptionFallback")}</p>
|
||||||
<Show when={rootPath()}>
|
<Show when={rootPath()}>
|
||||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
<p class="text-xs text-muted mt-1 font-mono break-all">
|
||||||
|
{t("filesystemBrowser.rootLabel", { root: rootPath() })}
|
||||||
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
Close
|
{t("filesystemBrowser.actions.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
|
<label class="w-full text-sm text-secondary mb-2 block">{t("filesystemBrowser.filterLabel")}</label>
|
||||||
<div class="selector-input-group">
|
<div class="selector-input-group">
|
||||||
<div class="flex items-center gap-2 px-3 text-muted">
|
<div class="flex items-center gap-2 px-3 text-muted">
|
||||||
<Search class="w-4 h-4" />
|
<Search class="w-4 h-4" />
|
||||||
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery()}
|
value={searchQuery()}
|
||||||
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
onInput={(event) => setSearchQuery(event.currentTarget.value)}
|
||||||
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
|
placeholder={
|
||||||
|
props.mode === "directories"
|
||||||
|
? t("filesystemBrowser.search.placeholder.directories")
|
||||||
|
: t("filesystemBrowser.search.placeholder.files")
|
||||||
|
}
|
||||||
class="selector-input"
|
class="selector-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
|
<p class="text-xs text-secondary uppercase tracking-wide">{t("filesystemBrowser.currentFolder.label")}</p>
|
||||||
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||||
>
|
>
|
||||||
Select Current
|
{t("filesystemBrowser.currentFolder.selectCurrent")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Loader2 class="w-4 h-4 animate-spin" />
|
<Loader2 class="w-4 h-4 animate-spin" />
|
||||||
<span>Loading {describeLoadingPath()}…</span>
|
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<Show when={loadingPath()}>
|
<Show when={loadingPath()}>
|
||||||
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
||||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||||
<span>Loading {describeLoadingPath()}…</span>
|
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={folderRows().length > 0}
|
when={folderRows().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||||
<p>No entries found.</p>
|
<p>{t("filesystemBrowser.empty.noEntries")}</p>
|
||||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||||
Retry
|
{t("filesystemBrowser.actions.retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<ArrowUpLeft class="w-4 h-4" />
|
<ArrowUpLeft class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="directory-browser-row-text">
|
<div class="directory-browser-row-text">
|
||||||
<span class="directory-browser-row-name">Up one level</span>
|
<span class="directory-browser-row-name">{t("filesystemBrowser.navigation.upOneLevel")}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
selectEntry()
|
selectEntry()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Select
|
{t("filesystemBrowser.actions.select")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
<kbd class="kbd">↓</kbd>
|
<kbd class="kbd">↓</kbd>
|
||||||
<span>Navigate</span>
|
<span>{t("filesystemBrowser.hints.navigate")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Select</span>
|
<span>{t("filesystemBrowser.hints.select")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Esc</kbd>
|
<kbd class="kbd">Esc</kbd>
|
||||||
<span>Close</span>
|
<span>{t("filesystemBrowser.hints.close")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default FileSystemBrowserDialog
|
export default FileSystemBrowserDialog
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import VersionPill from "./version-pill"
|
||||||
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
|
import { githubStars } from "../stores/github-stars"
|
||||||
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -19,13 +25,27 @@ interface FolderSelectionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||||
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
type LanguageOption = { value: Locale; label: string }
|
||||||
|
|
||||||
|
const languageOptions: LanguageOption[] = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "es", label: "Español" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "ru", label: "Русский" },
|
||||||
|
{ value: "ja", label: "日本語" },
|
||||||
|
{ value: "zh-Hans", label: "简体中文" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
const isLoading = () => Boolean(props.isLoading)
|
const isLoading = () => Boolean(props.isLoading)
|
||||||
@@ -56,6 +76,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedKey = e.key.toLowerCase()
|
const normalizedKey = e.key.toLowerCase()
|
||||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||||
const blockedKeys = [
|
const blockedKeys = [
|
||||||
@@ -164,16 +197,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderSelect(path: string) {
|
function handleFolderSelect(path: string) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openExternalLink = (url: string) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
@@ -181,7 +219,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
if (nativeDialogsAvailable) {
|
if (nativeDialogsAvailable) {
|
||||||
const fallbackPath = folders()[0]?.path
|
const fallbackPath = folders()[0]?.path
|
||||||
const selected = await openNativeFolderDialog({
|
const selected = await openNativeFolderDialog({
|
||||||
title: "Select Workspace",
|
title: t("folderSelection.dialog.title"),
|
||||||
defaultPath: fallbackPath,
|
defaultPath: fallbackPath,
|
||||||
})
|
})
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -228,167 +266,281 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
aria-busy={isLoading() ? "true" : "false"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
|
<div class="absolute top-4 left-6">
|
||||||
|
<Select<LanguageOption>
|
||||||
|
value={selectedLanguageOption()}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value) return
|
||||||
|
if (value.value === locale()) return
|
||||||
|
updatePreferences({ locale: value.value })
|
||||||
|
}}
|
||||||
|
options={languageOptions}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
class="selector-trigger"
|
||||||
|
aria-label={t("folderSelection.language.ariaLabel")}
|
||||||
|
title={t("folderSelection.language.ariaLabel")}
|
||||||
|
>
|
||||||
|
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<LanguageOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover min-w-[180px]">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
<div class="absolute top-4 right-6">
|
<div class="absolute top-4 right-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<div class="panel-empty-state-icon">
|
|
||||||
<Clock class="w-12 h-12 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
|
||||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="panel flex flex-col flex-1 min-h-0">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2 class="panel-title">Recent Folders</h2>
|
|
||||||
<p class="panel-subtitle">
|
|
||||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
|
||||||
<For each={folders()}>
|
|
||||||
{(folder, index) => (
|
|
||||||
<div
|
|
||||||
class="panel-list-item"
|
|
||||||
classList={{
|
|
||||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
|
||||||
"panel-list-item-disabled": isLoading(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 w-full px-1">
|
|
||||||
<button
|
|
||||||
data-folder-index={index()}
|
|
||||||
class="panel-list-item-content flex-1"
|
|
||||||
disabled={isLoading()}
|
|
||||||
onClick={() => handleFolderSelect(folder.path)}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (isLoading()) return
|
|
||||||
setFocusMode("recent")
|
|
||||||
setSelectedIndex(index())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-3 w-full">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
|
||||||
<span class="text-sm font-medium truncate text-primary">
|
|
||||||
{folder.path.split("/").pop()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
|
||||||
{getDisplayPath(folder.path)}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs mt-1 pl-6 text-muted">
|
|
||||||
{formatRelativeTime(folder.lastAccessed)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
|
||||||
<kbd class="kbd">↵</kbd>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleRemove(folder.path, e)}
|
|
||||||
disabled={isLoading()}
|
|
||||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
|
||||||
title="Remove from recent"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="panel shrink-0">
|
|
||||||
<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={() => void handleBrowse()}
|
|
||||||
disabled={props.isLoading}
|
|
||||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
|
||||||
onMouseEnter={() => setFocusMode("new")}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<FolderPlus class="w-4 h-4" />
|
|
||||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
|
||||||
</div>
|
|
||||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced settings section */}
|
|
||||||
<div class="panel-section w-full">
|
|
||||||
<button
|
|
||||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
|
||||||
class="panel-section-header w-full justify-between"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
|
||||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
|
||||||
</div>
|
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
|
<a
|
||||||
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
aria-label={t("folderSelection.links.github")}
|
||||||
|
title={t("folderSelection.links.github")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
|
aria-label={t("folderSelection.links.githubStars")}
|
||||||
|
title={t("folderSelection.links.githubStars")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star class="w-4 h-4" />
|
||||||
|
<Show when={githubStars() !== null}>
|
||||||
|
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
|
||||||
|
</Show>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
aria-label={t("folderSelection.links.discord")}
|
||||||
|
title={t("folderSelection.links.discord")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
openExternalLink(
|
||||||
|
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||||
<div class="panel-footer-hints">
|
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||||
<Show when={folders().length > 0}>
|
{/* Right column: recent folders */}
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
<kbd class="kbd">↑</kbd>
|
<Show
|
||||||
<kbd class="kbd">↓</kbd>
|
when={folders().length > 0}
|
||||||
<span>Navigate</span>
|
fallback={
|
||||||
</div>
|
<div class="panel panel-empty-state flex-1">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="panel-empty-state-icon">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<Clock class="w-12 h-12 mx-auto" />
|
||||||
<span>Select</span>
|
</div>
|
||||||
</div>
|
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
|
||||||
<div class="flex items-center gap-1.5">
|
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
|
||||||
<kbd class="kbd">Del</kbd>
|
</div>
|
||||||
<span>Remove</span>
|
}
|
||||||
|
>
|
||||||
|
<div class="panel flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
|
||||||
|
<p class="panel-subtitle">
|
||||||
|
{t(
|
||||||
|
folders().length === 1
|
||||||
|
? "folderSelection.recent.subtitle.one"
|
||||||
|
: "folderSelection.recent.subtitle.other",
|
||||||
|
{ count: folders().length },
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||||
|
ref={(el) => (recentListRef = el)}
|
||||||
|
>
|
||||||
|
<For each={folders()}>
|
||||||
|
{(folder, index) => (
|
||||||
|
<div
|
||||||
|
class="panel-list-item"
|
||||||
|
classList={{
|
||||||
|
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||||
|
"panel-list-item-disabled": isLoading(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 w-full px-1">
|
||||||
|
<button
|
||||||
|
data-folder-index={index()}
|
||||||
|
class="panel-list-item-content flex-1"
|
||||||
|
disabled={isLoading()}
|
||||||
|
onClick={() => handleFolderSelect(folder.path)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (isLoading()) return
|
||||||
|
setFocusMode("recent")
|
||||||
|
setSelectedIndex(index())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
|
<span class="text-sm font-medium truncate text-primary">
|
||||||
|
{folder.path.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||||
|
{getDisplayPath(folder.path)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs mt-1 pl-6 text-muted">
|
||||||
|
{formatRelativeTime(folder.lastAccessed)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||||
|
<kbd class="kbd">↵</kbd>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRemove(folder.path, e)}
|
||||||
|
disabled={isLoading()}
|
||||||
|
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||||
|
title={t("folderSelection.recent.remove")}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<Kbd shortcut="cmd+n" />
|
</div>
|
||||||
<span>Browse</span>
|
|
||||||
|
{/* Left column: version + browse + advanced settings */}
|
||||||
|
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
|
<div class="panel shrink-0">
|
||||||
|
<div class="panel-header hidden sm:block">
|
||||||
|
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
|
||||||
|
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleBrowse()}
|
||||||
|
disabled={props.isLoading}
|
||||||
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||||
|
onMouseEnter={() => setFocusMode("new")}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<FolderPlus class="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{props.isLoading
|
||||||
|
? t("folderSelection.browse.buttonOpening")
|
||||||
|
: t("folderSelection.browse.button")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced settings section */}
|
||||||
|
<div class="panel-section w-full">
|
||||||
|
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel shrink-0">
|
||||||
|
<div class="panel-body flex items-center justify-center">
|
||||||
|
<VersionPill />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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">
|
||||||
|
<kbd class="kbd">↑</kbd>
|
||||||
|
<kbd class="kbd">↓</kbd>
|
||||||
|
<span>{t("folderSelection.hints.navigate")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<kbd class="kbd">Enter</kbd>
|
||||||
|
<span>{t("folderSelection.hints.select")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<kbd class="kbd">Del</kbd>
|
||||||
|
<span>{t("folderSelection.hints.remove")}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Kbd shortcut="cmd+n" />
|
||||||
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,8 +549,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="folder-loading-overlay">
|
<div class="folder-loading-overlay">
|
||||||
<div class="folder-loading-indicator">
|
<div class="folder-loading-indicator">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
<p class="folder-loading-text">Starting instance…</p>
|
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
|
||||||
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
|
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -414,8 +566,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title="Select Workspace"
|
title={t("folderSelection.dialog.title")}
|
||||||
description="Select workspace to start coding."
|
description={t("folderSelection.dialog.description")}
|
||||||
onClose={() => setIsFolderBrowserOpen(false)}
|
onClose={() => setIsFolderBrowserOpen(false)}
|
||||||
onSelect={handleBrowserSelect}
|
onSelect={handleBrowserSelect}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
|
|||||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InfoViewProps {
|
interface InfoViewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -10,6 +11,7 @@ interface InfoViewProps {
|
|||||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||||
|
|
||||||
const InfoView: Component<InfoViewProps> = (props) => {
|
const InfoView: Component<InfoViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
let scrollRef: HTMLDivElement | undefined
|
let scrollRef: HTMLDivElement | undefined
|
||||||
const savedState = logsScrollState.get(props.instanceId)
|
const savedState = logsScrollState.get(props.instanceId)
|
||||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||||
@@ -90,18 +92,18 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
|
|
||||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<h2 class="panel-title">Server Logs</h2>
|
<h2 class="panel-title">{t("infoView.logs.title")}</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("infoView.logs.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||||
Hide server logs
|
{t("infoView.logs.actions.hide")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,17 +118,17 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="log-paused-state">
|
<div class="log-paused-state">
|
||||||
<p class="log-paused-title">Server logs are paused</p>
|
<p class="log-paused-title">{t("infoView.logs.paused.title")}</p>
|
||||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
<p class="log-paused-description">{t("infoView.logs.paused.description")}</p>
|
||||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("infoView.logs.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={logs().length > 0}
|
when={logs().length > 0}
|
||||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
fallback={<div class="log-empty-state">{t("infoView.logs.empty.waiting")}</div>}
|
||||||
>
|
>
|
||||||
<For each={logs()}>
|
<For each={logs()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
@@ -148,7 +150,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
class="scroll-to-bottom"
|
class="scroll-to-bottom"
|
||||||
>
|
>
|
||||||
<ChevronDown class="w-4 h-4" />
|
<ChevronDown class="w-4 h-4" />
|
||||||
Scroll to bottom
|
{t("infoView.logs.scrollToBottom")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceDisconnectedModalProps {
|
interface InstanceDisconnectedModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -8,8 +9,10 @@ interface InstanceDisconnectedModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
|
||||||
const folderLabel = props.folder || "this workspace"
|
const { t } = useI18n()
|
||||||
const reasonLabel = props.reason || "The server stopped responding"
|
|
||||||
|
const folderLabel = () => props.folder || t("instanceDisconnected.folderFallback")
|
||||||
|
const reasonLabel = () => props.reason || t("instanceDisconnected.reasonFallback")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} modal>
|
<Dialog open={props.open} modal>
|
||||||
@@ -18,25 +21,25 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">{t("instanceDisconnected.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
{t("instanceDisconnected.description", { folder: folderLabel() })}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
|
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
|
||||||
<p class="font-medium text-primary">Details</p>
|
<p class="font-medium text-primary">{t("instanceDisconnected.details.title")}</p>
|
||||||
<p class="mt-2 text-secondary">{reasonLabel}</p>
|
<p class="mt-2 text-secondary">{reasonLabel()}</p>
|
||||||
{props.folder && (
|
{props.folder && (
|
||||||
<p class="mt-2 text-secondary">
|
<p class="mt-2 text-secondary">
|
||||||
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
|
{t("instanceDisconnected.details.folderLabel")} <span class="font-mono text-primary break-all">{props.folder}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
|
||||||
Close Instance
|
{t("instanceDisconnected.actions.closeInstance")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, For, Show, createMemo } from "solid-js"
|
|||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
import InstanceServiceStatus from "./instance-service-status"
|
import InstanceServiceStatus from "./instance-service-status"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -9,6 +10,7 @@ interface InstanceInfoProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||||
@@ -26,11 +28,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="panel-title">Instance Information</h2>
|
<h2 class="panel-title">{t("instanceInfo.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body space-y-3">
|
<div class="panel-body space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
{currentInstance().folder}
|
{currentInstance().folder}
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +43,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Project
|
{t("instanceInfo.labels.project")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||||
{project().id}
|
{project().id}
|
||||||
@@ -51,7 +53,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={project().vcs}>
|
<Show when={project().vcs}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Version Control
|
{t("instanceInfo.labels.versionControl")}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs text-primary">
|
<div class="flex items-center gap-2 text-xs text-primary">
|
||||||
<svg
|
<svg
|
||||||
@@ -73,7 +75,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={binaryVersion()}>
|
<Show when={binaryVersion()}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
OpenCode Version
|
{t("instanceInfo.labels.opencodeVersion")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
v{binaryVersion()}
|
v{binaryVersion()}
|
||||||
@@ -84,7 +86,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={currentInstance().binaryPath}>
|
<Show when={currentInstance().binaryPath}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Binary Path
|
{t("instanceInfo.labels.binaryPath")}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
{currentInstance().binaryPath}
|
{currentInstance().binaryPath}
|
||||||
@@ -95,7 +97,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<Show when={environmentEntries().length > 0}>
|
<Show when={environmentEntries().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||||
Environment Variables ({environmentEntries().length})
|
{t("instanceInfo.labels.environmentVariables", { count: environmentEntries().length })}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={environmentEntries()}>
|
<For each={environmentEntries()}>
|
||||||
@@ -127,24 +129,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
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"
|
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>
|
</svg>
|
||||||
Loading...
|
{t("instanceInfo.loading")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div>
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">{t("instanceInfo.server.title")}</div>
|
||||||
<div class="space-y-1 text-xs">
|
<div class="space-y-1 text-xs">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">Port:</span>
|
<span class="text-secondary">{t("instanceInfo.server.port")}</span>
|
||||||
<span class="text-primary font-mono">{currentInstance().port}</span>
|
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">PID:</span>
|
<span class="text-secondary">{t("instanceInfo.server.pid")}</span>
|
||||||
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">Status:</span>
|
<span class="text-secondary">{t("instanceInfo.server.status")}</span>
|
||||||
<span class={`status-badge ${currentInstance().status}`}>
|
<span class={`status-badge ${currentInstance().status}`}>
|
||||||
<div
|
<div
|
||||||
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().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" : ""}`}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
|||||||
import Switch from "@suid/material/Switch"
|
import Switch from "@suid/material/Switch"
|
||||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -42,6 +43,7 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
const instance = metadataContext?.instance ?? (() => {
|
const instance = metadataContext?.instance ?? (() => {
|
||||||
if (props.initialInstance) {
|
if (props.initialInstance) {
|
||||||
@@ -112,12 +114,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
LSP Servers
|
{t("instanceServiceStatus.sections.lsp")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isLspLoading() && lspServers().length > 0}
|
when={!isLspLoading() && lspServers().length > 0}
|
||||||
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))}
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<For each={lspServers()}>
|
<For each={lspServers()}>
|
||||||
@@ -132,7 +134,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
<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"}`} />
|
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
<span>
|
||||||
|
{server.status === "connected"
|
||||||
|
? t("instanceServiceStatus.lsp.status.connected")
|
||||||
|
: t("instanceServiceStatus.lsp.status.error")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,12 +153,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
MCP Servers
|
{t("instanceServiceStatus.sections.mcp")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isMcpLoading() && mcpServers().length > 0}
|
when={!isMcpLoading() && mcpServers().length > 0}
|
||||||
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
fallback={renderEmptyState(isMcpLoading() ? t("instanceServiceStatus.mcp.loading") : t("instanceServiceStatus.mcp.empty"))}
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<For each={mcpServers()}>
|
<For each={mcpServers()}>
|
||||||
@@ -192,7 +198,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
disabled={switchDisabled()}
|
disabled={switchDisabled()}
|
||||||
color="success"
|
color="success"
|
||||||
size="small"
|
size="small"
|
||||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
inputProps={{ "aria-label": t("instanceServiceStatus.mcp.toggleAriaLabel", { name: server.name }) }}
|
||||||
onChange={(_, checked) => {
|
onChange={(_, checked) => {
|
||||||
if (switchDisabled()) return
|
if (switchDisabled()) return
|
||||||
void toggleMcpServer(server.name, Boolean(checked))
|
void toggleMcpServer(server.name, Boolean(checked))
|
||||||
@@ -222,12 +228,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
|
|||||||
<section class="space-y-1.5">
|
<section class="space-y-1.5">
|
||||||
<Show when={showHeadings()}>
|
<Show when={showHeadings()}>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
Plugins
|
{t("instanceServiceStatus.sections.plugins")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isPluginsLoading() && plugins().length > 0}
|
when={!isPluginsLoading() && plugins().length > 0}
|
||||||
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
|
fallback={renderEmptyState(isPluginsLoading() ? t("instanceServiceStatus.plugins.loading") : t("instanceServiceStatus.plugins.empty"))}
|
||||||
>
|
>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<For each={plugins()}>
|
<For each={plugins()}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, createMemo } from "solid-js"
|
|||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
||||||
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceTabProps {
|
interface InstanceTabProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
@@ -27,6 +28,7 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
||||||
const statusClassName = createMemo(() => {
|
const statusClassName = createMemo(() => {
|
||||||
const status = aggregatedStatus()
|
const status = aggregatedStatus()
|
||||||
@@ -35,13 +37,13 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
const statusTitle = createMemo(() => {
|
const statusTitle = createMemo(() => {
|
||||||
switch (aggregatedStatus()) {
|
switch (aggregatedStatus()) {
|
||||||
case "permission":
|
case "permission":
|
||||||
return "Waiting on permission"
|
return t("instanceTab.status.permission")
|
||||||
case "compacting":
|
case "compacting":
|
||||||
return "Compacting"
|
return t("instanceTab.status.compacting")
|
||||||
case "working":
|
case "working":
|
||||||
return "Working"
|
return t("instanceTab.status.working")
|
||||||
default:
|
default:
|
||||||
return "Idle"
|
return t("instanceTab.status.idle")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
<span
|
<span
|
||||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||||
title={statusTitle()}
|
title={statusTitle()}
|
||||||
aria-label={`Instance status: ${statusTitle()}`}
|
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
|
||||||
>
|
>
|
||||||
{aggregatedStatus() === "permission" ? (
|
{aggregatedStatus() === "permission" ? (
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
@@ -77,7 +79,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Close instance"
|
aria-label={t("instanceTab.actions.close.ariaLabel")}
|
||||||
>
|
>
|
||||||
<X class="w-3 h-3" />
|
<X class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import InstanceTab from "./instance-tab"
|
|||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp } from "lucide-solid"
|
import { Plus, MonitorUp } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -15,6 +16,7 @@ interface InstanceTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
return (
|
return (
|
||||||
<div class="tab-bar tab-bar-instance">
|
<div class="tab-bar tab-bar-instance">
|
||||||
<div class="tab-container" role="tablist">
|
<div class="tab-container" role="tablist">
|
||||||
@@ -34,8 +36,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
class="new-tab-button"
|
class="new-tab-button"
|
||||||
onClick={props.onNew}
|
onClick={props.onNew}
|
||||||
title="New instance (Cmd/Ctrl+N)"
|
title={t("instanceTabs.new.title")}
|
||||||
aria-label="New instance"
|
aria-label={t("instanceTabs.new.ariaLabel")}
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -54,8 +56,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
class="new-tab-button tab-remote-button"
|
class="new-tab-button tab-remote-button"
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
onClick={() => props.onOpenRemoteAccess?.()}
|
||||||
title="Remote connect"
|
title={t("instanceTabs.remote.title")}
|
||||||
aria-label="Remote connect"
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SessionRenameDialog from "./session-rename-dialog"
|
|||||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ interface InstanceWelcomeViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||||
@@ -47,7 +49,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
ctrl: !isMac(),
|
ctrl: !isMac(),
|
||||||
},
|
},
|
||||||
handler: () => {},
|
handler: () => {},
|
||||||
description: "New Session",
|
description: t("instanceWelcome.shortcuts.newSession"),
|
||||||
context: "global",
|
context: "global",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -248,10 +250,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp: number): string {
|
function formatTimestamp(timestamp: number): string {
|
||||||
@@ -291,7 +293,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to rename session:", error)
|
log.error("Failed to rename session:", error)
|
||||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
showToastNotification({ message: t("instanceWelcome.toasts.renameError"), variant: "error" })
|
||||||
} finally {
|
} finally {
|
||||||
setIsRenaming(false)
|
setIsRenaming(false)
|
||||||
}
|
}
|
||||||
@@ -333,11 +335,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
<p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
|
||||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
<p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
|
||||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||||
View Instance Info
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,8 +349,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="panel-empty-state-icon">
|
<div class="panel-empty-state-icon">
|
||||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
<p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
|
||||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
<p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -357,9 +359,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="panel-title">Resume Session</h2>
|
<h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
|
||||||
<p class="panel-subtitle">
|
<p class="panel-subtitle">
|
||||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
{parentSessions().length === 1
|
||||||
|
? t("instanceWelcome.resume.subtitle.one", { count: parentSessions().length })
|
||||||
|
: t("instanceWelcome.resume.subtitle.other", { count: parentSessions().length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||||
@@ -368,7 +372,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
class="button-tertiary lg:hidden flex-shrink-0"
|
class="button-tertiary lg:hidden flex-shrink-0"
|
||||||
onClick={openInstanceInfoOverlay}
|
onClick={openInstanceInfoOverlay}
|
||||||
>
|
>
|
||||||
View Instance Info
|
{t("instanceWelcome.actions.viewInstanceInfo")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,7 +408,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
"text-accent": isFocused(),
|
"text-accent": isFocused(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{session.title || "Untitled Session"}
|
{session.title || t("instanceWelcome.session.untitled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||||
@@ -421,7 +425,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
title={t("instanceWelcome.actions.renameTitle")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -433,7 +437,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
title={t("instanceWelcome.actions.deleteTitle")}
|
||||||
disabled={isSessionDeleting(session.id)}
|
disabled={isSessionDeleting(session.id)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -470,8 +474,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
|
|
||||||
<div class="panel flex-shrink-0">
|
<div class="panel flex-shrink-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2 class="panel-title">Start New Session</h2>
|
<h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
|
||||||
<p class="panel-subtitle">We’ll reuse your last agent/model automatically</p>
|
<p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -496,7 +500,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span>Create Session</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
@@ -524,7 +528,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||||
Close
|
{t("instanceWelcome.overlay.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||||
@@ -541,25 +545,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
<kbd class="kbd">↓</kbd>
|
<kbd class="kbd">↓</kbd>
|
||||||
<span>Navigate</span>
|
<span>{t("instanceWelcome.hints.navigate")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">PgUp</kbd>
|
<kbd class="kbd">PgUp</kbd>
|
||||||
<kbd class="kbd">PgDn</kbd>
|
<kbd class="kbd">PgDn</kbd>
|
||||||
<span>Jump</span>
|
<span>{t("instanceWelcome.hints.jump")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Home</kbd>
|
<kbd class="kbd">Home</kbd>
|
||||||
<kbd class="kbd">End</kbd>
|
<kbd class="kbd">End</kbd>
|
||||||
<span>First/Last</span>
|
<span>{t("instanceWelcome.hints.firstLast")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Enter</kbd>
|
<kbd class="kbd">Enter</kbd>
|
||||||
<span>Resume</span>
|
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">Del</kbd>
|
<kbd class="kbd">Del</kbd>
|
||||||
<span>Delete</span>
|
<span>{t("instanceWelcome.hints.delete")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,31 +26,38 @@ import MenuIcon from "@suid/icons-material/Menu"
|
|||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||||
|
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
import type { Command } from "../../lib/commands"
|
import type { Command } from "../../lib/commands"
|
||||||
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../types/session"
|
||||||
import {
|
import {
|
||||||
activeParentSessionId,
|
activeParentSessionId,
|
||||||
activeSessionId as activeSessionMap,
|
activeSessionId as activeSessionMap,
|
||||||
getSessionFamily,
|
getSessionFamily,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
|
sessions,
|
||||||
|
setActiveParentSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
} from "../../stores/sessions"
|
} from "../../stores/sessions"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import { clearSessionRenderCache } from "../message-block"
|
import { clearSessionRenderCache } from "../message-block"
|
||||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
|
||||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
|
||||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||||
import SessionList from "../session-list"
|
import SessionList from "../session-list"
|
||||||
import KeyboardHint from "../keyboard-hint"
|
import KeyboardHint from "../keyboard-hint"
|
||||||
|
import Kbd from "../kbd"
|
||||||
import InstanceWelcomeView from "../instance-welcome-view"
|
import InstanceWelcomeView from "../instance-welcome-view"
|
||||||
import InfoView from "../info-view"
|
import InfoView from "../info-view"
|
||||||
import InstanceServiceStatus from "../instance-service-status"
|
import InstanceServiceStatus from "../instance-service-status"
|
||||||
import AgentSelector from "../agent-selector"
|
import AgentSelector from "../agent-selector"
|
||||||
import ModelSelector from "../model-selector"
|
import ModelSelector from "../model-selector"
|
||||||
|
import ThinkingSelector from "../thinking-selector"
|
||||||
import CommandPalette from "../command-palette"
|
import CommandPalette from "../command-palette"
|
||||||
import Kbd from "../kbd"
|
import PermissionNotificationBanner from "../permission-notification-banner"
|
||||||
|
import PermissionApprovalModal from "../permission-approval-modal"
|
||||||
import { TodoListView } from "../tool-call/renderers/todo"
|
import { TodoListView } from "../tool-call/renderers/todo"
|
||||||
import ContextUsagePanel from "../session/context-usage-panel"
|
import ContextUsagePanel from "../session/context-usage-panel"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
@@ -60,6 +67,7 @@ import { getLogger } from "../../lib/logger"
|
|||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
import {
|
import {
|
||||||
SESSION_SIDEBAR_EVENT,
|
SESSION_SIDEBAR_EVENT,
|
||||||
type SessionSidebarRequestAction,
|
type SessionSidebarRequestAction,
|
||||||
@@ -86,7 +94,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360
|
|||||||
const RIGHT_DRAWER_WIDTH = 260
|
const RIGHT_DRAWER_WIDTH = 260
|
||||||
const MIN_RIGHT_DRAWER_WIDTH = 200
|
const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||||
const MAX_RIGHT_DRAWER_WIDTH = 380
|
const MAX_RIGHT_DRAWER_WIDTH = 380
|
||||||
const SESSION_CACHE_LIMIT = 2
|
const SESSION_CACHE_LIMIT = 5
|
||||||
const APP_BAR_HEIGHT = 56
|
const APP_BAR_HEIGHT = 56
|
||||||
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||||
@@ -114,6 +122,8 @@ function persistPinState(side: "left" | "right", value: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
|
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
|
||||||
const [leftPinned, setLeftPinned] = createSignal(true)
|
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||||
@@ -141,6 +151,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
])
|
])
|
||||||
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
|
||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
|
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||||
|
|
||||||
@@ -266,6 +277,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
requestAnimationFrame(() => measureDrawerHost())
|
requestAnimationFrame(() => measureDrawerHost())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||||
|
return sessions().get(props.instance.id) ?? new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionThreads = createMemo(() => getSessionThreads(props.instance.id))
|
||||||
|
|
||||||
const activeSessions = createMemo(() => {
|
const activeSessions = createMemo(() => {
|
||||||
const parentId = activeParentSessionId().get(props.instance.id)
|
const parentId = activeParentSessionId().get(props.instance.id)
|
||||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||||
@@ -343,6 +360,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return "disconnected"
|
return "disconnected"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionStatusLabel = () => {
|
||||||
|
const status = connectionStatus()
|
||||||
|
if (status === "connected") return t("instanceShell.connection.connected")
|
||||||
|
if (status === "connecting") return t("instanceShell.connection.connecting")
|
||||||
|
if (status === "error" || status === "disconnected") return t("instanceShell.connection.disconnected")
|
||||||
|
return t("instanceShell.connection.unknown")
|
||||||
|
}
|
||||||
|
|
||||||
const handleCommandPaletteClick = () => {
|
const handleCommandPaletteClick = () => {
|
||||||
showCommandPalette(props.instance.id)
|
showCommandPalette(props.instance.id)
|
||||||
}
|
}
|
||||||
@@ -373,9 +398,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
const instancePaletteCommands = createMemo(() => props.paletteCommands())
|
||||||
|
|
||||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
|
||||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||||
|
|
||||||
const keyboardShortcuts = createMemo(() =>
|
const keyboardShortcuts = createMemo(() =>
|
||||||
@@ -421,6 +444,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusVariantSelectorControl = () => {
|
||||||
|
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const pending = pendingSidebarAction()
|
const pending = pendingSidebarAction()
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
@@ -433,7 +464,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
setPendingSidebarAction(null)
|
setPendingSidebarAction(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
|
const handled =
|
||||||
|
action === "focus-agent-selector"
|
||||||
|
? focusAgentSelectorControl()
|
||||||
|
: action === "focus-model-selector"
|
||||||
|
? focusModelSelectorControl()
|
||||||
|
: focusVariantSelectorControl()
|
||||||
if (handled) {
|
if (handled) {
|
||||||
setPendingSidebarAction(null)
|
setPendingSidebarAction(null)
|
||||||
}
|
}
|
||||||
@@ -477,7 +513,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSessionSelect = (sessionId: string) => {
|
const handleSessionSelect = (sessionId: string) => {
|
||||||
setActiveSession(props.instance.id, sessionId)
|
if (sessionId === "info") {
|
||||||
|
setActiveSession(props.instance.id, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = allInstanceSessions().get(sessionId)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
if (session.parentId === null) {
|
||||||
|
setActiveParentSession(props.instance.id, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = session.parentId
|
||||||
|
if (!parentId) return
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setActiveParentSession(props.instance.id, parentId)
|
||||||
|
setActiveSession(props.instance.id, sessionId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -522,23 +577,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionsMap = activeSessions()
|
const instanceSessions = allInstanceSessions()
|
||||||
const parentId = parentSessionIdForInstance()
|
|
||||||
const activeId = activeSessionIdForInstance()
|
const activeId = activeSessionIdForInstance()
|
||||||
|
|
||||||
setCachedSessionIds((current) => {
|
setCachedSessionIds((current) => {
|
||||||
const next: string[] = []
|
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||||
const append = (id: string | null) => {
|
|
||||||
|
const touch = (id: string | null) => {
|
||||||
if (!id || id === "info") return
|
if (!id || id === "info") return
|
||||||
if (!sessionsMap.has(id)) return
|
if (!instanceSessions.has(id)) return
|
||||||
if (next.includes(id)) return
|
|
||||||
next.push(id)
|
const index = next.indexOf(id)
|
||||||
|
if (index !== -1) {
|
||||||
|
next.splice(index, 1)
|
||||||
|
}
|
||||||
|
next.unshift(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
append(parentId)
|
touch(activeId)
|
||||||
append(activeId)
|
|
||||||
|
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||||
|
|
||||||
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
|
||||||
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
|
||||||
const trimmedSet = new Set(trimmed)
|
const trimmedSet = new Set(trimmed)
|
||||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
@@ -654,7 +713,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
||||||
|
|
||||||
|
|
||||||
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
||||||
if (leftPinned()) return "pinned"
|
if (leftPinned()) return "pinned"
|
||||||
@@ -668,16 +727,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
const leftAppBarButtonLabel = () => {
|
const leftAppBarButtonLabel = () => {
|
||||||
const state = leftDrawerState()
|
const state = leftDrawerState()
|
||||||
if (state === "pinned") return "Left drawer pinned"
|
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
|
||||||
if (state === "floating-closed") return "Open left drawer"
|
if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open")
|
||||||
return "Close left drawer"
|
return t("instanceShell.leftDrawer.toggle.close")
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightAppBarButtonLabel = () => {
|
const rightAppBarButtonLabel = () => {
|
||||||
const state = rightDrawerState()
|
const state = rightDrawerState()
|
||||||
if (state === "pinned") return "Right drawer pinned"
|
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
|
||||||
if (state === "floating-closed") return "Open right drawer"
|
if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open")
|
||||||
return "Close right drawer"
|
return t("instanceShell.rightDrawer.toggle.close")
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftAppBarButtonIcon = () => {
|
const leftAppBarButtonIcon = () => {
|
||||||
@@ -695,7 +754,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const pinLeftDrawer = () => {
|
const pinLeftDrawer = () => {
|
||||||
blurIfInside(leftDrawerContentEl())
|
blurIfInside(leftDrawerContentEl())
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setLeftPinned(true)
|
setLeftPinned(true)
|
||||||
@@ -807,40 +866,45 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
|
||||||
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||||
|
{t("instanceShell.leftPanel.sessionsTitle")}
|
||||||
|
</span>
|
||||||
<div class="session-sidebar-shortcuts">
|
<div class="session-sidebar-shortcuts">
|
||||||
<Show when={keyboardShortcuts().length}>
|
<Show when={keyboardShortcuts().length}>
|
||||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={!isPhoneLayout()}>
|
<IconButton
|
||||||
<IconButton
|
size="small"
|
||||||
size="small"
|
color="inherit"
|
||||||
color="inherit"
|
aria-label={t("instanceShell.leftPanel.instanceInfo")}
|
||||||
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
|
title={t("instanceShell.leftPanel.instanceInfo")}
|
||||||
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
onClick={() => handleSessionSelect("info")}
|
||||||
>
|
>
|
||||||
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
<InfoOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Show>
|
<Show when={!isPhoneLayout()}>
|
||||||
</div>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
|
||||||
|
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
|
||||||
|
>
|
||||||
|
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
<SessionList
|
<SessionList
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessions={activeSessions()}
|
threads={sessionThreads()}
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
activeSessionId={activeSessionIdForInstance()}
|
||||||
onSelect={handleSessionSelect}
|
onSelect={handleSessionSelect}
|
||||||
onClose={(id) => {
|
|
||||||
const result = props.onCloseSession(id)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to close session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNew={() => {
|
onNew={() => {
|
||||||
const result = props.onNewSession()
|
const result = props.onNewSession()
|
||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
@@ -864,21 +928,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessionId={activeSession().id}
|
sessionId={activeSession().id}
|
||||||
currentModel={activeSession().model}
|
currentModel={activeSession().model}
|
||||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -891,19 +948,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const renderPlanSectionContent = () => {
|
const renderPlanSectionContent = () => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") {
|
if (!sessionId || sessionId === "info") {
|
||||||
return <p class="text-xs text-secondary">Select a session to view plan.</p>
|
return <p class="text-xs text-secondary">{t("instanceShell.plan.noSessionSelected")}</p>
|
||||||
}
|
}
|
||||||
const todoState = latestTodoState()
|
const todoState = latestTodoState()
|
||||||
if (!todoState) {
|
if (!todoState) {
|
||||||
return <p class="text-xs text-secondary">Nothing planned yet.</p>
|
return <p class="text-xs text-secondary">{t("instanceShell.plan.empty")}</p>
|
||||||
}
|
}
|
||||||
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} />
|
return <TodoListView state={todoState} emptyLabel={t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderBackgroundProcesses = () => {
|
const renderBackgroundProcesses = () => {
|
||||||
const processes = backgroundProcessList()
|
const processes = backgroundProcessList()
|
||||||
if (processes.length === 0) {
|
if (processes.length === 0) {
|
||||||
return <p class="text-xs text-secondary">No background processes.</p>
|
return <p class="text-xs text-secondary">{t("instanceShell.backgroundProcesses.empty")}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -914,9 +971,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
<span class="text-xs font-semibold text-primary">{process.title}</span>
|
||||||
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
|
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
|
||||||
<span>Status: {process.status}</span>
|
<span>{t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||||
<Show when={typeof process.outputSizeBytes === "number"}>
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||||
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span>
|
<span>
|
||||||
|
{t("instanceShell.backgroundProcesses.output", {
|
||||||
|
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -925,8 +986,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
onClick={() => openBackgroundOutput(process)}
|
onClick={() => openBackgroundOutput(process)}
|
||||||
aria-label="Output"
|
aria-label={t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
title="Output"
|
title={t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
>
|
>
|
||||||
<TerminalSquare class="h-4 w-4" />
|
<TerminalSquare class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -935,8 +996,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
disabled={process.status !== "running"}
|
disabled={process.status !== "running"}
|
||||||
onClick={() => stopBackgroundProcess(process.id)}
|
onClick={() => stopBackgroundProcess(process.id)}
|
||||||
aria-label="Stop"
|
aria-label={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
title="Stop"
|
title={t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
>
|
>
|
||||||
<XOctagon class="h-4 w-4" />
|
<XOctagon class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -944,8 +1005,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
onClick={() => terminateBackgroundProcess(process.id)}
|
onClick={() => terminateBackgroundProcess(process.id)}
|
||||||
aria-label="Terminate"
|
aria-label={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
title="Terminate"
|
title={t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -960,17 +1021,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
label: "Plan",
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
render: renderPlanSectionContent,
|
render: renderPlanSectionContent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "background-processes",
|
id: "background-processes",
|
||||||
label: "Background Shells",
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
render: renderBackgroundProcesses,
|
render: renderBackgroundProcesses,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mcp",
|
id: "mcp",
|
||||||
label: "MCP Servers",
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -982,7 +1043,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lsp",
|
id: "lsp",
|
||||||
label: "LSP Servers",
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -994,7 +1055,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plugins",
|
id: "plugins",
|
||||||
label: "Plugins",
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -1022,14 +1083,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
|
||||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
|
||||||
Status Panel
|
{t("instanceShell.rightPanel.title")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={!isPhoneLayout()}>
|
<Show when={!isPhoneLayout()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"}
|
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
|
||||||
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
|
||||||
>
|
>
|
||||||
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
@@ -1053,7 +1114,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Accordion.Header>
|
<Accordion.Header>
|
||||||
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
|
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
|
||||||
<span>{section.label}</span>
|
<span>{t(section.labelKey)}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
|
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
@@ -1222,24 +1283,30 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-1 justify-center">
|
<div class="flex flex-wrap items-center gap-1 justify-center">
|
||||||
|
<PermissionNotificationBanner
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
onClick={() => setPermissionModalOpen(true)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label="Open command palette"
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
>
|
>
|
||||||
Command Palette
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`status-indicator ${connectionStatusClass()}`}
|
class={`status-indicator ${connectionStatusClass()}`}
|
||||||
aria-label={`Connection ${connectionStatus()}`}
|
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||||
>
|
>
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1257,57 +1324,71 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||||
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||||
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
|
</span>
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setLeftToggleButtonEl}
|
ref={setLeftToggleButtonEl}
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={handleLeftAppBarButtonClick}
|
onClick={handleLeftAppBarButtonClick}
|
||||||
aria-label={leftAppBarButtonLabel()}
|
aria-label={leftAppBarButtonLabel()}
|
||||||
size="small"
|
size="small"
|
||||||
aria-expanded={leftDrawerState() !== "floating-closed"}
|
aria-expanded={leftDrawerState() !== "floating-closed"}
|
||||||
disabled={leftDrawerState() === "pinned"}
|
disabled={leftDrawerState() === "pinned"}
|
||||||
>
|
>
|
||||||
{leftAppBarButtonIcon()}
|
{leftAppBarButtonIcon()}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<Show when={!showingInfoView()}>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
{t("instanceShell.metrics.usedLabel")}
|
||||||
</div>
|
</span>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
||||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
</div>
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
</div>
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">
|
||||||
</Show>
|
{t("instanceShell.metrics.availableLabel")}
|
||||||
</div>
|
</span>
|
||||||
|
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
|
||||||
<button
|
<PermissionNotificationBanner
|
||||||
type="button"
|
instanceId={props.instance.id}
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
onClick={() => setPermissionModalOpen(true)}
|
||||||
onClick={handleCommandPaletteClick}
|
/>
|
||||||
aria-label="Open command palette"
|
<button
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
type="button"
|
||||||
>
|
class="connection-status-button px-2 py-0.5 text-xs"
|
||||||
Command Palette
|
onClick={handleCommandPaletteClick}
|
||||||
</button>
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
<span class="connection-status-shortcut-hint">
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
>
|
||||||
</span>
|
{t("instanceShell.commandPalette.button")}
|
||||||
</div>
|
</button>
|
||||||
|
<span class="connection-status-shortcut-hint">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="session-toolbar-right flex items-center gap-3">
|
<div class="session-toolbar-right flex items-center gap-3">
|
||||||
@@ -1315,19 +1396,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<Show when={connectionStatus() === "connected"}>
|
<Show when={connectionStatus() === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connected</span>
|
<span class="status-text">{t("instanceShell.connection.connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={connectionStatus() === "connecting"}>
|
<Show when={connectionStatus() === "connecting"}>
|
||||||
<span class="status-indicator connecting">
|
<span class="status-indicator connecting">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connecting...</span>
|
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||||
<span class="status-indicator disconnected">
|
<span class="status-indicator disconnected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Disconnected</span>
|
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -1363,8 +1444,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||||
<p class="mb-2">No session selected</p>
|
<p class="mb-2">{t("instanceShell.empty.title")}</p>
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
<p class="text-sm">{t("instanceShell.empty.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1429,6 +1510,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
process={selectedBackgroundProcess()}
|
process={selectedBackgroundProcess()}
|
||||||
onClose={closeBackgroundOutput}
|
onClose={closeBackgroundOutput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PermissionApprovalModal
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
isOpen={permissionModalOpen()}
|
||||||
|
onClose={() => setPermissionModalOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface LogsViewProps {
|
interface LogsViewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -9,6 +10,7 @@ interface LogsViewProps {
|
|||||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||||
|
|
||||||
const LogsView: Component<LogsViewProps> = (props) => {
|
const LogsView: Component<LogsViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
let scrollRef: HTMLDivElement | undefined
|
let scrollRef: HTMLDivElement | undefined
|
||||||
const savedState = logsScrollState.get(props.instanceId)
|
const savedState = logsScrollState.get(props.instanceId)
|
||||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||||
@@ -83,18 +85,18 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="log-container">
|
<div class="log-container">
|
||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
|
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">{t("logsView.title")}</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("logsView.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
|
||||||
Hide server logs
|
{t("logsView.actions.hide")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +105,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
|
||||||
<div class="env-vars-container">
|
<div class="env-vars-container">
|
||||||
<div class="env-vars-title">
|
<div class="env-vars-title">
|
||||||
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
|
{t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={Object.entries(instance()?.environmentVariables!)}>
|
<For each={Object.entries(instance()?.environmentVariables!)}>
|
||||||
@@ -130,17 +132,17 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
when={streamingEnabled()}
|
when={streamingEnabled()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="log-paused-state">
|
<div class="log-paused-state">
|
||||||
<p class="log-paused-title">Server logs are paused</p>
|
<p class="log-paused-title">{t("logsView.paused.title")}</p>
|
||||||
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
|
<p class="log-paused-description">{t("logsView.paused.description")}</p>
|
||||||
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
<button type="button" class="button-primary" onClick={handleEnableLogs}>
|
||||||
Show server logs
|
{t("logsView.actions.show")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={logs().length > 0}
|
when={logs().length > 0}
|
||||||
fallback={<div class="log-empty-state">Waiting for server output...</div>}
|
fallback={<div class="log-empty-state">{t("logsView.empty.waiting")}</div>}
|
||||||
>
|
>
|
||||||
<For each={logs()}>
|
<For each={logs()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
@@ -160,7 +162,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
|
|||||||
class="scroll-to-bottom"
|
class="scroll-to-bottom"
|
||||||
>
|
>
|
||||||
<ChevronDown class="w-4 h-4" />
|
<ChevronDown class="w-4 h-4" />
|
||||||
Scroll to bottom
|
{t("logsView.scrollToBottom")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
|||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ interface MarkdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: MarkdownProps) {
|
export function Markdown(props: MarkdownProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestedText = ""
|
||||||
@@ -145,14 +147,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const copyText = copyButton.querySelector(".copy-text")
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
if (copyText) {
|
if (copyText) {
|
||||||
if (success) {
|
if (success) {
|
||||||
copyText.textContent = "Copied!"
|
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyText.textContent = "Copy"
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} else {
|
} else {
|
||||||
copyText.textContent = "Failed"
|
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyText.textContent = "Copy"
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import { FoldVertical } from "lucide-solid"
|
import { FoldVertical } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
@@ -11,6 +11,7 @@ import { messageStoreBus } from "../stores/message-v2/bus"
|
|||||||
import { formatTokenTotal } from "../lib/formatters"
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
@@ -82,8 +83,20 @@ interface TaskSessionLocation {
|
|||||||
parentId: string | null
|
parentId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
if (preferredInstanceId) {
|
||||||
|
const session = sessions().get(preferredInstanceId)?.get(sessionId)
|
||||||
|
if (session) {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
instanceId: preferredInstanceId,
|
||||||
|
parentId: session.parentId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allSessions = sessions()
|
const allSessions = sessions()
|
||||||
for (const [instanceId, sessionMap] of allSessions) {
|
for (const [instanceId, sessionMap] of allSessions) {
|
||||||
const session = sessionMap?.get(sessionId)
|
const session = sessionMap?.get(sessionId)
|
||||||
@@ -224,6 +237,7 @@ interface MessageBlockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlock(props: MessageBlockProps) {
|
export default function MessageBlock(props: MessageBlockProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
@@ -235,16 +249,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const index = props.messageIndex
|
const index = props.messageIndex
|
||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
const info = messageInfo()
|
|
||||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
// Intentionally untracked: messageInfoVersion updates should not trigger
|
||||||
const infoTimestamp =
|
// a full message block rebuild; record revision is the invalidation key.
|
||||||
typeof infoTime.completed === "number"
|
const info = untrack(messageInfo)
|
||||||
? 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 = [
|
const cacheSignature = [
|
||||||
current.id,
|
current.id,
|
||||||
current.revision,
|
current.revision,
|
||||||
@@ -252,8 +261,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
props.showThinking() ? 1 : 0,
|
props.showThinking() ? 1 : 0,
|
||||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
props.showUsageMetrics() ? 1 : 0,
|
props.showUsageMetrics() ? 1 : 0,
|
||||||
infoTimestamp,
|
|
||||||
infoErrorName,
|
|
||||||
].join("|")
|
].join("|")
|
||||||
|
|
||||||
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
const cachedBlock = sessionCache.messageBlocks.get(current.id)
|
||||||
@@ -447,7 +454,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const hasToolState =
|
const hasToolState =
|
||||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
|
||||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -460,8 +467,8 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
<div class="tool-call-header-label">
|
<div class="tool-call-header-label">
|
||||||
<div class="tool-call-header-meta">
|
<div class="tool-call-header-meta">
|
||||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||||
<span>Tool Call</span>
|
<span>{t("messageBlock.tool.header")}</span>
|
||||||
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
|
<span class="tool-name">{toolItem.toolPart.tool || t("messageBlock.tool.unknown")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={taskSessionId}>
|
<Show when={taskSessionId}>
|
||||||
<button
|
<button
|
||||||
@@ -469,9 +476,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!taskLocation}
|
disabled={!taskLocation}
|
||||||
onClick={handleGoToTaskSession}
|
onClick={handleGoToTaskSession}
|
||||||
title={!taskLocation ? "Session not available yet" : "Go to session"}
|
title={!taskLocation ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
|
||||||
>
|
>
|
||||||
Go to Session
|
{t("messageBlock.tool.goToSession.label")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,8 +540,9 @@ interface StepCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||||
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
|
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||||
|
|
||||||
const containerClass = () =>
|
const containerClass = () =>
|
||||||
@@ -545,7 +553,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
|||||||
class={containerClass()}
|
class={containerClass()}
|
||||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Session compaction"
|
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||||
>
|
>
|
||||||
<div class="message-compaction-row">
|
<div class="message-compaction-row">
|
||||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||||
@@ -556,6 +564,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StepCard(props: StepCardProps) {
|
function StepCard(props: StepCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -602,12 +611,12 @@ function StepCard(props: StepCardProps) {
|
|||||||
|
|
||||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
|
||||||
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
|
||||||
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
|
||||||
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
|
||||||
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
|
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
|
||||||
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
|
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -642,8 +651,8 @@ function StepCard(props: StepCardProps) {
|
|||||||
<div class="message-step-title-left">
|
<div class="message-step-title-left">
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
|
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||||
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
|
<Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,6 +679,7 @@ interface ReasoningCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -741,19 +751,29 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-expanded={expanded()}
|
aria-expanded={expanded()}
|
||||||
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
||||||
<span>Thinking</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
<Show when={agentIdentifier()}>
|
||||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
{(value) => (
|
||||||
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={modelIdentifier()}>
|
||||||
|
{(value) => (
|
||||||
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
<span class="message-reasoning-meta">
|
<span class="message-reasoning-meta">
|
||||||
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
|
<span class="message-reasoning-indicator">
|
||||||
|
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
||||||
|
</span>
|
||||||
<span class="message-reasoning-time">{timestamp()}</span>
|
<span class="message-reasoning-time">{timestamp()}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -761,7 +781,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
|
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { partHasRenderableText } from "../types/message"
|
|||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -19,6 +20,7 @@ interface MessageItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
@@ -49,15 +51,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
const url = part.url || ""
|
const url = part.url || ""
|
||||||
if (url.startsWith("data:")) {
|
if (url.startsWith("data:")) {
|
||||||
return "attachment"
|
return t("messageItem.attachment.defaultName")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
const segments = parsed.pathname.split("/")
|
const segments = parsed.pathname.split("/")
|
||||||
return segments.pop() || "attachment"
|
return segments.pop() || t("messageItem.attachment.defaultName")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fallback = url.split("/").pop()
|
const fallback = url.split("/").pop()
|
||||||
return fallback && fallback.length > 0 ? fallback : "attachment"
|
return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +114,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const error = info.error
|
const error = info.error
|
||||||
if (error.name === "ProviderAuthError") {
|
if (error.name === "ProviderAuthError") {
|
||||||
return error.data?.message || "Authentication error"
|
return error.data?.message || t("messageItem.errors.authenticationFallback")
|
||||||
}
|
}
|
||||||
if (error.name === "MessageOutputLengthError") {
|
if (error.name === "MessageOutputLengthError") {
|
||||||
return "Message output length exceeded"
|
return t("messageItem.errors.outputLengthExceeded")
|
||||||
}
|
}
|
||||||
if (error.name === "MessageAbortedError") {
|
if (error.name === "MessageAbortedError") {
|
||||||
return "Request was aborted"
|
return t("messageItem.errors.requestAborted")
|
||||||
}
|
}
|
||||||
if (error.name === "UnknownError") {
|
if (error.name === "UnknownError") {
|
||||||
return error.data?.message || "Unknown error occurred"
|
return error.data?.message || t("messageItem.errors.unknownFallback")
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -170,7 +172,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
? "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)]"
|
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||||
|
|
||||||
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant"))
|
||||||
|
|
||||||
const agentIdentifier = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
@@ -195,10 +197,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const agent = agentIdentifier()
|
const agent = agentIdentifier()
|
||||||
const model = modelIdentifier()
|
const model = modelIdentifier()
|
||||||
if (agent) {
|
if (agent) {
|
||||||
segments.push(`Agent: ${agent}`)
|
segments.push(t("messageItem.agentMeta.agentLabel", { agent }))
|
||||||
}
|
}
|
||||||
if (model) {
|
if (model) {
|
||||||
segments.push(`Model: ${model}`)
|
segments.push(t("messageItem.agentMeta.modelLabel", { model }))
|
||||||
}
|
}
|
||||||
return segments.join(" • ")
|
return segments.join(" • ")
|
||||||
}
|
}
|
||||||
@@ -220,30 +222,30 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleRevert}
|
onClick={handleRevert}
|
||||||
title="Revert to this message"
|
title={t("messageItem.actions.revertTitle")}
|
||||||
aria-label="Revert to this message"
|
aria-label={t("messageItem.actions.revertTitle")}
|
||||||
>
|
>
|
||||||
Revert
|
{t("messageItem.actions.revert")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => props.onFork?.(props.record.id)}
|
onClick={() => props.onFork?.(props.record.id)}
|
||||||
title="Fork from this message"
|
title={t("messageItem.actions.forkTitle")}
|
||||||
aria-label="Fork from this message"
|
aria-label={t("messageItem.actions.forkTitle")}
|
||||||
>
|
>
|
||||||
Fork
|
{t("messageItem.actions.fork")}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy message"
|
title={t("messageItem.actions.copyTitle")}
|
||||||
aria-label="Copy message"
|
aria-label={t("messageItem.actions.copyTitle")}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||||
Copied!
|
{t("messageItem.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,11 +254,11 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy message"
|
title={t("messageItem.actions.copyTitle")}
|
||||||
aria-label="Copy message"
|
aria-label={t("messageItem.actions.copyTitle")}
|
||||||
>
|
>
|
||||||
<Show when={copied()} fallback="Copy">
|
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
|
||||||
Copied!
|
{t("messageItem.actions.copied")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -269,7 +271,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
<div class="message-queued-badge">QUEUED</div>
|
<div class="message-queued-badge">{t("messageItem.status.queued")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
@@ -278,7 +280,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
<Show when={isGenerating()}>
|
<Show when={isGenerating()}>
|
||||||
<div class="message-generating">
|
<div class="message-generating">
|
||||||
<span class="generating-spinner">⏳</span> Generating...
|
<span class="generating-spinner">⏳</span> {t("messageItem.status.generating")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -319,7 +321,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
class="attachment-download"
|
class="attachment-download"
|
||||||
aria-label={`Download ${name}`}
|
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
>
|
>
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path 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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
@@ -340,12 +342,12 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
<Show when={props.record.status === "sending"}>
|
<Show when={props.record.status === "sending"}>
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> Sending...
|
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.record.status === "error"}>
|
<Show when={props.record.status === "error"}>
|
||||||
<div class="message-error">⚠ Message failed to send</div>
|
<div class="message-error">⚠ {t("messageItem.status.failedToSend")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
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_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"
|
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||||
@@ -17,6 +18,7 @@ interface MessageListHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||||
@@ -29,7 +31,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="session-sidebar-menu-button"
|
class="session-sidebar-menu-button"
|
||||||
onClick={() => props.onSidebarToggle?.()}
|
onClick={() => props.onSidebarToggle?.()}
|
||||||
aria-label="Open session list"
|
aria-label={t("messageListHeader.sidebar.openSessionListAriaLabel")}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -39,11 +41,11 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<div class="connection-status-text connection-status-info">
|
<div class="connection-status-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
||||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<div class={METRIC_CHIP_CLASS}>
|
||||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
||||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,8 +53,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
|
|
||||||
<div class="connection-status-text connection-status-shortcut">
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
<div class="connection-status-shortcut-action">
|
<div class="connection-status-shortcut-action">
|
||||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
<button
|
||||||
Command Palette
|
type="button"
|
||||||
|
class="connection-status-button"
|
||||||
|
onClick={props.onCommandPalette}
|
||||||
|
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||||
|
>
|
||||||
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
@@ -64,19 +71,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<Show when={props.connectionStatus === "connected"}>
|
<Show when={props.connectionStatus === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connected</span>
|
<span class="status-text">{t("messageListHeader.connection.connected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.connectionStatus === "connecting"}>
|
<Show when={props.connectionStatus === "connecting"}>
|
||||||
<span class="status-indicator connecting">
|
<span class="status-indicator connecting">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Connecting...</span>
|
<span class="status-text">{t("messageListHeader.connection.connecting")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||||
<span class="status-indicator disconnected">
|
<span class="status-indicator disconnected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
<span class="status-text">Disconnected</span>
|
<span class="status-text">{t("messageListHeader.connection.disconnected")}</span>
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { getSessionInfo } from "../stores/sessions"
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
const SCROLL_SCOPE = "session"
|
||||||
@@ -31,6 +32,7 @@ export interface MessageSectionProps {
|
|||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
|
const { t } = useI18n()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
@@ -107,7 +109,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
seenTimelineMessageIds.add(messageId)
|
seenTimelineMessageIds.add(messageId)
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
if (seenTimelineSegmentKeys.has(key)) return
|
if (seenTimelineSegmentKeys.has(key)) return
|
||||||
@@ -121,7 +123,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
function appendTimelineForMessage(messageId: string) {
|
function appendTimelineForMessage(messageId: string) {
|
||||||
const record = untrack(() => store().getMessage(messageId))
|
const record = untrack(() => store().getMessage(messageId))
|
||||||
if (!record) return
|
if (!record) return
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
if (built.length === 0) return
|
if (built.length === 0) return
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
@@ -558,7 +560,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
previousLastTimelineMessageId = lastId
|
previousLastTimelineMessageId = lastId
|
||||||
previousLastTimelinePartCount = partCount
|
previousLastTimelinePartCount = partCount
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||||
const newSegments: TimelineSegment[] = []
|
const newSegments: TimelineSegment[] = []
|
||||||
built.forEach((segment) => {
|
built.forEach((segment) => {
|
||||||
const key = makeTimelineKey(segment)
|
const key = makeTimelineKey(segment)
|
||||||
@@ -753,19 +755,19 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-content">
|
<div class="empty-state-content">
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
|
||||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<h3>Start a conversation</h3>
|
<h3>{t("messageSection.empty.title")}</h3>
|
||||||
<p>Type a message below or open the Command Palette:</p>
|
<p>{t("messageSection.empty.description")}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>Command Palette</span>
|
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
</li>
|
</li>
|
||||||
<li>Ask about your codebase</li>
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||||
<li>
|
<li>
|
||||||
Attach files with <code>@</code>
|
{t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -775,7 +777,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<Show when={props.loading}>
|
<Show when={props.loading}>
|
||||||
<div class="loading-state">
|
<div class="loading-state">
|
||||||
<div class="spinner" />
|
<div class="spinner" />
|
||||||
<p>Loading messages...</p>
|
<p>{t("messageSection.loading.messages")}</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -803,7 +805,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
<div class="message-scroll-button-wrapper">
|
<div class="message-scroll-button-wrapper">
|
||||||
<Show when={showScrollTopButton()}>
|
<Show when={showScrollTopButton()}>
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={t("messageSection.scroll.toFirstAriaLabel")}>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -812,7 +814,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="message-scroll-button"
|
class="message-scroll-button"
|
||||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||||
aria-label="Scroll to latest message"
|
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -828,10 +830,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
>
|
>
|
||||||
<div class="message-quote-button-group">
|
<div class="message-quote-button-group">
|
||||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||||
Add as quote
|
{t("messageSection.quote.addAsQuote")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||||
Add as code
|
{t("messageSection.quote.addAsCode")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { ClientPart } from "../types/message"
|
|||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
|
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export interface TimelineSegment {
|
|||||||
tooltip: string
|
tooltip: string
|
||||||
shortLabel?: string
|
shortLabel?: string
|
||||||
variant?: "auto" | "manual"
|
variant?: "auto" | "manual"
|
||||||
|
toolPartIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageTimelineProps {
|
interface MessageTimelineProps {
|
||||||
@@ -28,14 +30,6 @@ interface MessageTimelineProps {
|
|||||||
showToolSegments?: boolean
|
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
|
const MAX_TOOLTIP_LENGTH = 220
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
@@ -47,6 +41,7 @@ interface PendingSegment {
|
|||||||
toolTitles: string[]
|
toolTitles: string[]
|
||||||
toolTypeLabels: string[]
|
toolTypeLabels: string[]
|
||||||
toolIcons: string[]
|
toolIcons: string[]
|
||||||
|
toolPartIds: string[]
|
||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +83,7 @@ function collectReasoningText(part: ClientPart): string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTextFromPart(part: ClientPart): string {
|
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
if (typeof (part as any).text === "string") {
|
if (typeof (part as any).text === "string") {
|
||||||
return (part as any).text as string
|
return (part as any).text as string
|
||||||
@@ -104,26 +99,28 @@ function collectTextFromPart(part: ClientPart): string {
|
|||||||
}
|
}
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const filename = (part as any)?.filename
|
const filename = (part as any)?.filename
|
||||||
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
return typeof filename === "string" && filename.length > 0
|
||||||
|
? t("messageTimeline.text.filePrefix", { filename })
|
||||||
|
: t("messageTimeline.text.attachment")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolTitle(part: ToolCallPart): string {
|
function getToolTitle(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
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
|
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||||
if (title) return title
|
if (title) return title
|
||||||
if (typeof part.tool === "string" && part.tool.length > 0) {
|
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||||
return part.tool
|
return part.tool
|
||||||
}
|
}
|
||||||
return TOOL_FALLBACK_LABEL
|
return t("messageTimeline.tool.fallbackLabel")
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolTypeLabel(part: ToolCallPart): string {
|
function getToolTypeLabel(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
|
||||||
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||||
return part.tool.trim().slice(0, 4)
|
return part.tool.trim().slice(0, 4)
|
||||||
}
|
}
|
||||||
return TOOL_FALLBACK_LABEL.slice(0, 4)
|
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTextsTooltip(texts: string[], fallback: string): string {
|
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||||
@@ -137,20 +134,34 @@ function formatTextsTooltip(texts: string[], fallback: string): string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolTooltip(titles: string[]): string {
|
function formatToolTooltip(
|
||||||
|
titles: string[],
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
if (titles.length === 0) {
|
if (titles.length === 0) {
|
||||||
return TOOL_FALLBACK_LABEL
|
return t("messageTimeline.tool.fallbackLabel")
|
||||||
}
|
}
|
||||||
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
return truncateText(`${t("messageTimeline.tool.fallbackLabel")}: ${titles.join(", ")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
export function buildTimelineSegments(
|
||||||
|
instanceId: string,
|
||||||
|
record: MessageRecord,
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => string,
|
||||||
|
): TimelineSegment[] {
|
||||||
if (!record) return []
|
if (!record) return []
|
||||||
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||||
if (!orderedParts || orderedParts.length === 0) {
|
if (!orderedParts || orderedParts.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const segmentLabel = (type: TimelineSegmentType) => {
|
||||||
|
if (type === "user") return t("messageTimeline.segment.user.label")
|
||||||
|
if (type === "assistant") return t("messageTimeline.segment.assistant.label")
|
||||||
|
if (type === "compaction") return t("messageTimeline.segment.compaction.label")
|
||||||
|
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
const result: TimelineSegment[] = []
|
const result: TimelineSegment[] = []
|
||||||
let segmentIndex = 0
|
let segmentIndex = 0
|
||||||
let pending: PendingSegment | null = null
|
let pending: PendingSegment | null = null
|
||||||
@@ -162,14 +173,14 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
}
|
}
|
||||||
const isToolSegment = pending.type === "tool"
|
const isToolSegment = pending.type === "tool"
|
||||||
const label = isToolSegment
|
const label = isToolSegment
|
||||||
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
||||||
: SEGMENT_LABELS[pending.type]
|
: segmentLabel(pending.type)
|
||||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||||
const tooltip = isToolSegment
|
const tooltip = isToolSegment
|
||||||
? formatToolTooltip(pending.toolTitles)
|
? formatToolTooltip(pending.toolTitles, t)
|
||||||
: formatTextsTooltip(
|
: formatTextsTooltip(
|
||||||
[...pending.texts, ...pending.reasoningTexts],
|
[...pending.texts, ...pending.reasoningTexts],
|
||||||
pending.type === "user" ? "User message" : "Assistant response",
|
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||||
)
|
)
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
@@ -179,6 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
label,
|
label,
|
||||||
tooltip,
|
tooltip,
|
||||||
shortLabel,
|
shortLabel,
|
||||||
|
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
pending = null
|
pending = null
|
||||||
@@ -187,7 +199,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||||
if (!pending || pending.type !== type) {
|
if (!pending || pending.type !== type) {
|
||||||
flushPending()
|
flushPending()
|
||||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
|
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
||||||
}
|
}
|
||||||
return pending!
|
return pending!
|
||||||
}
|
}
|
||||||
@@ -201,9 +213,12 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
const target = ensureSegment("tool")
|
const target = ensureSegment("tool")
|
||||||
const toolPart = part as ToolCallPart
|
const toolPart = part as ToolCallPart
|
||||||
target.toolTitles.push(getToolTitle(toolPart))
|
target.toolTitles.push(getToolTitle(toolPart, t))
|
||||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
||||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +239,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
label: SEGMENT_LABELS.compaction,
|
label: segmentLabel("compaction"),
|
||||||
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
|
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||||
variant: isAuto ? "auto" : "manual",
|
variant: isAuto ? "auto" : "manual",
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
@@ -236,7 +251,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = collectTextFromPart(part)
|
const text = collectTextFromPart(part, t)
|
||||||
if (text.trim().length === 0) continue
|
if (text.trim().length === 0) continue
|
||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -252,6 +267,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const buttonRefs = new Map<string, HTMLButtonElement>()
|
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||||
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||||
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||||
@@ -354,14 +370,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
||||||
<For each={props.segments}>
|
<For each={props.segments}>
|
||||||
{(segment) => {
|
{(segment) => {
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
const isActive = () => props.activeMessageId === segment.messageId
|
const isActive = () => props.activeMessageId === segment.messageId
|
||||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
|
|
||||||
|
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 = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
|
if (hasActivePermission()) {
|
||||||
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
}
|
}
|
||||||
if (segment.type === "compaction") {
|
if (segment.type === "compaction") {
|
||||||
@@ -378,7 +410,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
type="button"
|
type="button"
|
||||||
data-variant={segment.variant}
|
data-variant={segment.variant}
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
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-current={isActive() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
@@ -416,4 +448,3 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default MessageTimeline
|
export default MessageTimeline
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Combobox } from "@kobalte/core/combobox"
|
import { Combobox } from "@kobalte/core/combobox"
|
||||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { providers, fetchProviders } from "../stores/sessions"
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown, Star } from "lucide-solid"
|
||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
|
import Kbd from "./kbd"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -21,10 +24,22 @@ interface FlatModel extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelSelector(props: ModelSelectorProps) {
|
export default function ModelSelector(props: ModelSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
const [manualAll, setManualAll] = createSignal(false)
|
||||||
|
const [explicitFavorites, setExplicitFavorites] = createSignal(false)
|
||||||
|
const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false)
|
||||||
|
const [searchDirty, setSearchDirty] = createSignal(false)
|
||||||
|
const [initialQuery, setInitialQuery] = createSignal("")
|
||||||
|
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
|
||||||
|
const [inputValue, setInputValue] = createSignal("")
|
||||||
let triggerRef!: HTMLButtonElement
|
let triggerRef!: HTMLButtonElement
|
||||||
let searchInputRef!: HTMLInputElement
|
let searchInputRef!: HTMLInputElement
|
||||||
|
let listboxRef!: HTMLUListElement
|
||||||
|
let suppressNextClose = false
|
||||||
|
let wasFavoritesOnlyEnabled = false
|
||||||
|
let wasCurrentModelFavorite = false
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (instanceProviders().length === 0) {
|
if (instanceProviders().length === 0) {
|
||||||
@@ -43,61 +58,232 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const favoriteKeySet = createMemo(() => {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const item of preferences().modelFavorites ?? []) {
|
||||||
|
if (item.providerId && item.modelId) {
|
||||||
|
result.add(`${item.providerId}/${item.modelId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const favoriteModels = createMemo<FlatModel[]>(() => {
|
||||||
|
const keys = favoriteKeySet()
|
||||||
|
if (keys.size === 0) return []
|
||||||
|
return allModels().filter((m) => keys.has(m.key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasFavorites = createMemo(() => favoriteModels().length > 0)
|
||||||
|
|
||||||
const currentModelValue = createMemo(() =>
|
const currentModelValue = createMemo(() =>
|
||||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentModelIsFavorite = createMemo(() => {
|
||||||
|
const current = props.currentModel
|
||||||
|
return favoriteKeySet().has(`${current.providerId}/${current.modelId}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentModelKey = createMemo(() => {
|
||||||
|
const current = props.currentModel
|
||||||
|
return `${current.providerId}/${current.modelId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchActive = createMemo(() => {
|
||||||
|
if (!searchDirty()) return false
|
||||||
|
const next = inputValue().trim()
|
||||||
|
return next.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const favoritesOnlyEnabled = createMemo(() => {
|
||||||
|
if (searchActive()) return false
|
||||||
|
if (manualAll()) return false
|
||||||
|
if (!hasFavorites()) return false
|
||||||
|
return explicitFavorites() || autoFavoritesEligibleAtOpen()
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleOptions = createMemo<FlatModel[]>(() => {
|
||||||
|
if (!favoritesOnlyEnabled()) {
|
||||||
|
return allModels()
|
||||||
|
}
|
||||||
|
return favoriteModels()
|
||||||
|
})
|
||||||
|
|
||||||
const handleChange = async (value: FlatModel | null) => {
|
const handleChange = async (value: FlatModel | null) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const customFilter = (option: FlatModel, inputValue: string) => {
|
const customFilter = (option: FlatModel, rawInput: string) => {
|
||||||
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
|
if (!searchDirty()) return true
|
||||||
|
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isOpen()) {
|
if (isOpen()) {
|
||||||
|
setManualAll(false)
|
||||||
|
setExplicitFavorites(false)
|
||||||
|
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
|
||||||
|
setSearchDirty(false)
|
||||||
|
setInitialQuery("")
|
||||||
|
setInputValue("")
|
||||||
|
setInitialQueryReady(false)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const seeded = searchInputRef?.value ?? ""
|
||||||
|
setInitialQuery(seeded)
|
||||||
|
setInputValue(seeded)
|
||||||
|
setInitialQueryReady(true)
|
||||||
searchInputRef?.focus()
|
searchInputRef?.focus()
|
||||||
|
searchInputRef?.select()
|
||||||
}, 100)
|
}, 100)
|
||||||
|
} else {
|
||||||
|
setInitialQueryReady(false)
|
||||||
|
setSearchDirty(false)
|
||||||
|
setAutoFavoritesEligibleAtOpen(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isOpen()) {
|
||||||
|
wasFavoritesOnlyEnabled = favoritesOnlyEnabled()
|
||||||
|
wasCurrentModelFavorite = currentModelIsFavorite()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowFavoritesOnlyEnabled = favoritesOnlyEnabled()
|
||||||
|
const nowCurrentModelFavorite = currentModelIsFavorite()
|
||||||
|
|
||||||
|
if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const key = currentModelKey()
|
||||||
|
const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null
|
||||||
|
target?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled
|
||||||
|
wasCurrentModelFavorite = nowCurrentModelFavorite
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
|
||||||
|
const next = event.currentTarget.value
|
||||||
|
setInputValue(next)
|
||||||
|
if (!initialQueryReady()) return
|
||||||
|
if (searchDirty()) return
|
||||||
|
if (next !== initialQuery()) {
|
||||||
|
setSearchDirty(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preventListboxPress = (event: PointerEvent | MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation?.()
|
||||||
|
event.stopPropagation()
|
||||||
|
suppressNextClose = true
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressNextClose = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFavoritesOnly = () => {
|
||||||
|
if (!hasFavorites()) return
|
||||||
|
if (searchActive()) return
|
||||||
|
|
||||||
|
if (favoritesOnlyEnabled()) {
|
||||||
|
setManualAll(true)
|
||||||
|
setExplicitFavorites(false)
|
||||||
|
setAutoFavoritesEligibleAtOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setExplicitFavorites(true)
|
||||||
|
setManualAll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAllModels = () => {
|
||||||
|
setManualAll(true)
|
||||||
|
setExplicitFavorites(false)
|
||||||
|
setAutoFavoritesEligibleAtOpen(false)
|
||||||
|
setTimeout(() => searchInputRef?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="sidebar-selector">
|
<div class="sidebar-selector">
|
||||||
<Combobox<FlatModel>
|
<Combobox<FlatModel>
|
||||||
|
open={isOpen()}
|
||||||
value={currentModelValue()}
|
value={currentModelValue()}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={(next) => {
|
||||||
options={allModels()}
|
if (!next && suppressNextClose) return
|
||||||
|
setIsOpen(next)
|
||||||
|
}}
|
||||||
|
options={visibleOptions()}
|
||||||
optionValue="key"
|
optionValue="key"
|
||||||
optionTextValue="searchText"
|
optionTextValue="searchText"
|
||||||
optionLabel="name"
|
optionLabel="name"
|
||||||
placeholder="Search models..."
|
placeholder={t("modelSelector.placeholder.search")}
|
||||||
defaultFilter={customFilter}
|
defaultFilter={customFilter}
|
||||||
allowsEmptyCollection
|
allowsEmptyCollection
|
||||||
itemComponent={(itemProps) => (
|
itemComponent={(itemProps) => {
|
||||||
<Combobox.Item
|
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
|
||||||
item={itemProps.item}
|
return (
|
||||||
class="selector-option"
|
<Combobox.Item
|
||||||
>
|
item={itemProps.item}
|
||||||
<div class="selector-option-content">
|
class="selector-option"
|
||||||
<Combobox.ItemLabel class="selector-option-label">
|
>
|
||||||
{itemProps.item.rawValue.name}
|
<>
|
||||||
</Combobox.ItemLabel>
|
<div class="selector-option-content">
|
||||||
<Combobox.ItemDescription class="selector-option-description">
|
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
|
||||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/
|
<Combobox.ItemDescription class="selector-option-description">
|
||||||
{itemProps.item.rawValue.id}
|
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
|
||||||
</Combobox.ItemDescription>
|
</Combobox.ItemDescription>
|
||||||
</div>
|
</div>
|
||||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Combobox.ItemIndicator>
|
</Combobox.ItemIndicator>
|
||||||
</Combobox.Item>
|
<button
|
||||||
)}
|
type="button"
|
||||||
|
class="selector-option-star"
|
||||||
|
data-active={isFavorite()}
|
||||||
|
aria-label={
|
||||||
|
isFavorite()
|
||||||
|
? t("modelSelector.favorite.remove")
|
||||||
|
: t("modelSelector.favorite.add")
|
||||||
|
}
|
||||||
|
onPointerDown={preventListboxPress}
|
||||||
|
onPointerUp={preventListboxPress}
|
||||||
|
onMouseDown={preventListboxPress}
|
||||||
|
onMouseUp={preventListboxPress}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
suppressNextClose = true
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressNextClose = false
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleFavoriteModelPreference({
|
||||||
|
providerId: itemProps.item.rawValue.providerId,
|
||||||
|
modelId: itemProps.item.rawValue.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill={isFavorite() ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
</Combobox.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||||
<Combobox.Input class="sr-only" data-model-selector />
|
<Combobox.Input class="sr-only" data-model-selector />
|
||||||
@@ -105,9 +291,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
class="selector-trigger"
|
class="selector-trigger"
|
||||||
>
|
>
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
Model: {currentModelValue()?.name ?? "None"}
|
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||||
</span>
|
</span>
|
||||||
{currentModelValue() && (
|
{currentModelValue() && (
|
||||||
<span class="selector-trigger-secondary">
|
<span class="selector-trigger-secondary">
|
||||||
@@ -115,6 +301,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||||
|
<Kbd shortcut="cmd+shift+m" />
|
||||||
|
</span>
|
||||||
<Combobox.Icon class="selector-trigger-icon">
|
<Combobox.Icon class="selector-trigger-icon">
|
||||||
<ChevronDown class="w-3 h-3" />
|
<ChevronDown class="w-3 h-3" />
|
||||||
</Combobox.Icon>
|
</Combobox.Icon>
|
||||||
@@ -124,13 +313,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
<Combobox.Portal>
|
<Combobox.Portal>
|
||||||
<Combobox.Content class="selector-popover">
|
<Combobox.Content class="selector-popover">
|
||||||
<div class="selector-search-container">
|
<div class="selector-search-container">
|
||||||
<Combobox.Input
|
<div class="selector-input-group">
|
||||||
ref={searchInputRef}
|
<Combobox.Input
|
||||||
class="selector-search-input"
|
ref={searchInputRef}
|
||||||
placeholder="Search models..."
|
class="selector-search-input flex-1 min-w-0"
|
||||||
/>
|
placeholder={t("modelSelector.placeholder.search")}
|
||||||
|
onInput={handleSearchInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-favorites-toggle"
|
||||||
|
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
|
||||||
|
aria-pressed={favoritesOnlyEnabled()}
|
||||||
|
disabled={!hasFavorites() || searchActive()}
|
||||||
|
data-active={favoritesOnlyEnabled()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
toggleFavoritesOnly()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
|
||||||
|
<div class="selector-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-option selector-option-action w-full"
|
||||||
|
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
showAllModels()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Combobox.Listbox class="selector-listbox" />
|
|
||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ interface OpenCodeBinarySelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
@@ -103,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validatingPaths().has(path)) {
|
if (validatingPaths().has(path)) {
|
||||||
return { valid: false, error: "Already validating" }
|
return { valid: false, error: t("opencodeBinarySelector.validation.alreadyValidating") }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -139,7 +141,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
if (nativeDialogsAvailable) {
|
if (nativeDialogsAvailable) {
|
||||||
const selected = await openNativeFileDialog({
|
const selected = await openNativeFileDialog({
|
||||||
title: "Select OpenCode Binary",
|
title: t("opencodeBinarySelector.dialog.title"),
|
||||||
})
|
})
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setCustomPath(selected)
|
setCustomPath(selected)
|
||||||
@@ -160,7 +162,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
setCustomPath("")
|
setCustomPath("")
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
} else {
|
} else {
|
||||||
setValidationError(validation.error || "Invalid OpenCode binary")
|
setValidationError(validation.error || t("opencodeBinarySelector.validation.invalidBinary"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +204,14 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDisplayName(path: string): string {
|
function getDisplayName(path: string): string {
|
||||||
if (path === "opencode") return "opencode (system PATH)"
|
if (path === "opencode") return t("opencodeBinarySelector.display.systemPath", { name: "opencode" })
|
||||||
const parts = path.split(/[/\\]/)
|
const parts = path.split(/[/\\]/)
|
||||||
return parts[parts.length - 1] ?? path
|
return parts[parts.length - 1] ?? path
|
||||||
}
|
}
|
||||||
@@ -221,13 +223,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header flex items-center justify-between gap-3">
|
<div class="panel-header flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="panel-title">OpenCode Binary</h3>
|
<h3 class="panel-title">{t("opencodeBinarySelector.title")}</h3>
|
||||||
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
|
<p class="panel-subtitle">{t("opencodeBinarySelector.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Show when={validating()}>
|
<Show when={validating()}>
|
||||||
<div class="selector-loading text-xs">
|
<div class="selector-loading text-xs">
|
||||||
<Loader2 class="selector-loading-spinner" />
|
<Loader2 class="selector-loading-spinner" />
|
||||||
<span>Checking versions…</span>
|
<span>{t("opencodeBinarySelector.status.checkingVersions")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +247,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
placeholder="Enter path to opencode binary…"
|
placeholder={t("opencodeBinarySelector.customPath.placeholder")}
|
||||||
class="selector-input"
|
class="selector-input"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -255,7 +257,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
class="selector-button selector-button-primary"
|
class="selector-button selector-button-primary"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
Add
|
{t("opencodeBinarySelector.actions.add")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,7 +268,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" />
|
<FolderOpen class="w-4 h-4" />
|
||||||
Browse for Binary…
|
{t("opencodeBinarySelector.actions.browse")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={validationError()}>
|
<Show when={validationError()}>
|
||||||
@@ -308,16 +310,16 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
|
||||||
<Show when={versionLabel()}>
|
<Show when={versionLabel()}>
|
||||||
<span class="selector-badge-version">v{versionLabel()}</span>
|
<span class="selector-badge-version">{t("opencodeBinarySelector.versionLabel", { version: versionLabel() })}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isPathValidating(binary.path)}>
|
<Show when={isPathValidating(binary.path)}>
|
||||||
<span class="selector-badge-time">Checking…</span>
|
<span class="selector-badge-time">{t("opencodeBinarySelector.status.checking")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isDefault && binary.lastUsed}>
|
<Show when={!isDefault && binary.lastUsed}>
|
||||||
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isDefault}>
|
<Show when={isDefault}>
|
||||||
<span class="selector-badge-time">Use binary from system PATH</span>
|
<span class="selector-badge-time">{t("opencodeBinarySelector.badge.systemPath")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +330,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
class="p-2 text-muted hover:text-primary"
|
class="p-2 text-muted hover:text-primary"
|
||||||
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
onClick={(event) => handleRemoveBinary(binary.path, event)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
title="Remove binary"
|
title={t("opencodeBinarySelector.actions.removeTitle")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -343,8 +345,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
<FileSystemBrowserDialog
|
<FileSystemBrowserDialog
|
||||||
open={isBinaryBrowserOpen()}
|
open={isBinaryBrowserOpen()}
|
||||||
mode="files"
|
mode="files"
|
||||||
title="Select OpenCode Binary"
|
title={t("opencodeBinarySelector.dialog.title")}
|
||||||
description="Browse files exposed by the CLI server."
|
description={t("opencodeBinarySelector.dialog.description")}
|
||||||
onClose={() => setIsBinaryBrowserOpen(false)}
|
onClose={() => setIsBinaryBrowserOpen(false)}
|
||||||
onSelect={handleBinaryBrowserSelect}
|
onSelect={handleBinaryBrowserSelect}
|
||||||
/>
|
/>
|
||||||
@@ -353,4 +355,3 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default OpenCodeBinarySelector
|
export default OpenCodeBinarySelector
|
||||||
|
|
||||||
|
|||||||
435
packages/ui/src/components/permission-approval-modal.tsx
Normal file
435
packages/ui/src/components/permission-approval-modal.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||||
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
|
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||||
|
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import {
|
||||||
|
activeInterruption,
|
||||||
|
getPermissionQueue,
|
||||||
|
getQuestionQueue,
|
||||||
|
getQuestionEnqueuedAtForInstance,
|
||||||
|
sendPermissionResponse,
|
||||||
|
} from "../stores/instances"
|
||||||
|
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
|
|
||||||
|
interface PermissionApprovalModalProps {
|
||||||
|
instanceId: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedToolCall = {
|
||||||
|
messageId: string
|
||||||
|
sessionId: string
|
||||||
|
toolPart: Extract<import("../types/message").ClientPart, { type: "tool" }>
|
||||||
|
messageVersion: number
|
||||||
|
partVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolCallFromPermission(
|
||||||
|
instanceId: string,
|
||||||
|
permission: PermissionRequestLike,
|
||||||
|
): ResolvedToolCall | null {
|
||||||
|
const sessionId = getPermissionSessionId(permission)
|
||||||
|
const messageId = getPermissionMessageId(permission)
|
||||||
|
if (!sessionId || !messageId) return null
|
||||||
|
|
||||||
|
const store = messageStoreBus.getInstance(instanceId)
|
||||||
|
if (!store) return null
|
||||||
|
|
||||||
|
const record = store.getMessage(messageId)
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
const metadata = ((permission as any).metadata || {}) as Record<string, unknown>
|
||||||
|
const directPartId =
|
||||||
|
(permission as any).partID ??
|
||||||
|
(permission as any).partId ??
|
||||||
|
(metadata as any).partID ??
|
||||||
|
(metadata as any).partId ??
|
||||||
|
undefined
|
||||||
|
|
||||||
|
const callId = getPermissionCallId(permission)
|
||||||
|
|
||||||
|
const findToolPart = (partId: string) => {
|
||||||
|
const partRecord = record.parts?.[partId]
|
||||||
|
const part = partRecord?.data
|
||||||
|
if (!part || part.type !== "tool") return null
|
||||||
|
return {
|
||||||
|
toolPart: part as ResolvedToolCall["toolPart"],
|
||||||
|
partVersion: partRecord.revision ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof directPartId === "string" && directPartId.length > 0) {
|
||||||
|
const resolved = findToolPart(directPartId)
|
||||||
|
if (resolved) {
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
sessionId,
|
||||||
|
toolPart: resolved.toolPart,
|
||||||
|
messageVersion: record.revision,
|
||||||
|
partVersion: resolved.partVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callId) {
|
||||||
|
for (const partId of record.partIds) {
|
||||||
|
const partRecord = record.parts?.[partId]
|
||||||
|
const part = partRecord?.data as any
|
||||||
|
if (!part || part.type !== "tool") continue
|
||||||
|
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
|
||||||
|
if (partCallId === callId && typeof part.id === "string" && part.id.length > 0) {
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
sessionId,
|
||||||
|
toolPart: part as ResolvedToolCall["toolPart"],
|
||||||
|
messageVersion: record.revision,
|
||||||
|
partVersion: partRecord.revision ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
|
||||||
|
const sessionId = getQuestionSessionId(request)
|
||||||
|
const messageId = getQuestionMessageId(request)
|
||||||
|
if (!sessionId || !messageId) return null
|
||||||
|
|
||||||
|
const store = messageStoreBus.getInstance(instanceId)
|
||||||
|
if (!store) return null
|
||||||
|
|
||||||
|
const record = store.getMessage(messageId)
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
const callId = getQuestionCallId(request)
|
||||||
|
if (!callId) return null
|
||||||
|
|
||||||
|
for (const partId of record.partIds) {
|
||||||
|
const partRecord = record.parts?.[partId]
|
||||||
|
const part = partRecord?.data as any
|
||||||
|
if (!part || part.type !== "tool") continue
|
||||||
|
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
|
||||||
|
if (partCallId !== callId) continue
|
||||||
|
|
||||||
|
if (typeof part.id !== "string" || part.id.length === 0) continue
|
||||||
|
return {
|
||||||
|
messageId,
|
||||||
|
sessionId,
|
||||||
|
toolPart: part as ResolvedToolCall["toolPart"],
|
||||||
|
messageVersion: record.revision,
|
||||||
|
partVersion: partRecord?.revision ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
|
||||||
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
|
||||||
|
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
const setPermissionBusy = (permissionId: string, busy: boolean) => {
|
||||||
|
setPermissionSubmitting((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (busy) next.add(permissionId)
|
||||||
|
else next.delete(permissionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPermissionItemError = (permissionId: string, message: string | null) => {
|
||||||
|
setPermissionError((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (!message) next.delete(permissionId)
|
||||||
|
else next.set(permissionId, message)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject") {
|
||||||
|
const permissionId = permission?.id
|
||||||
|
if (!permissionId) return
|
||||||
|
|
||||||
|
if (permissionSubmitting().has(permissionId)) return
|
||||||
|
|
||||||
|
setPermissionBusy(permissionId, true)
|
||||||
|
setPermissionItemError(permissionId, null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = getPermissionSessionId(permission) || ""
|
||||||
|
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
|
||||||
|
} catch (error) {
|
||||||
|
setPermissionItemError(
|
||||||
|
permissionId,
|
||||||
|
error instanceof Error ? error.message : t("permissionApproval.errors.unableToUpdatePermission"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setPermissionBusy(permissionId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
|
||||||
|
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
|
||||||
|
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
|
||||||
|
|
||||||
|
type InterruptionItem =
|
||||||
|
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
|
||||||
|
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
|
||||||
|
|
||||||
|
const orderedQueue = createMemo<InterruptionItem[]>(() => {
|
||||||
|
const permissions = permissionQueue().map((permission) => ({
|
||||||
|
kind: "permission" as const,
|
||||||
|
id: permission.id,
|
||||||
|
sessionId: getPermissionSessionId(permission) || "",
|
||||||
|
createdAt: (permission as any)?.time?.created ?? Date.now(),
|
||||||
|
payload: permission,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const questions = questionQueue().map((question) => ({
|
||||||
|
kind: "question" as const,
|
||||||
|
id: question.id,
|
||||||
|
sessionId: getQuestionSessionId(question) || "",
|
||||||
|
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
|
||||||
|
payload: question,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasRequests = createMemo(() => orderedQueue().length > 0)
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.isOpen) return
|
||||||
|
document.addEventListener("keydown", closeOnEscape)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", closeOnEscape))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.isOpen) return
|
||||||
|
if (orderedQueue().length === 0) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoadSession(sessionId: string) {
|
||||||
|
if (!sessionId) return
|
||||||
|
setLoadingSession(sessionId)
|
||||||
|
try {
|
||||||
|
await loadMessages(props.instanceId, sessionId)
|
||||||
|
} finally {
|
||||||
|
setLoadingSession((current) => (current === sessionId ? null : current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGoToSession(sessionId: string) {
|
||||||
|
if (!sessionId) return
|
||||||
|
|
||||||
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
|
const parentId = session?.parentId ?? session?.id
|
||||||
|
if (parentId) {
|
||||||
|
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSessionFromList(props.instanceId, sessionId)
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.isOpen}>
|
||||||
|
<div class="permission-center-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div class="permission-center-modal" role="dialog" aria-modal="true" aria-labelledby="permission-center-title">
|
||||||
|
<div class="permission-center-modal-header">
|
||||||
|
<div class="permission-center-modal-title-row">
|
||||||
|
<h2 id="permission-center-title" class="permission-center-modal-title">
|
||||||
|
{t("permissionApproval.title")}
|
||||||
|
</h2>
|
||||||
|
<Show when={orderedQueue().length > 0}>
|
||||||
|
<span class="permission-center-modal-count">{orderedQueue().length}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="permission-center-modal-close"
|
||||||
|
onClick={props.onClose}
|
||||||
|
aria-label={t("permissionApproval.actions.closeAriaLabel")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="permission-center-modal-body">
|
||||||
|
<Show when={hasRequests()} fallback={<div class="permission-center-empty">{t("permissionApproval.empty")}</div>}>
|
||||||
|
<div class="permission-center-list" role="list">
|
||||||
|
<For each={orderedQueue()}>
|
||||||
|
{(item) => {
|
||||||
|
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
|
||||||
|
const sessionId = () => item.sessionId
|
||||||
|
|
||||||
|
const resolved = createMemo(() => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return resolveToolCallFromPermission(props.instanceId, item.payload)
|
||||||
|
}
|
||||||
|
return resolveToolCallFromQuestion(props.instanceId, item.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showFallback = () => !resolved()
|
||||||
|
|
||||||
|
const kindLabel = () =>
|
||||||
|
item.kind === "permission"
|
||||||
|
? t("permissionApproval.kind.permission")
|
||||||
|
: t("permissionApproval.kind.question")
|
||||||
|
|
||||||
|
const primaryTitle = () => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return getPermissionDisplayTitle(item.payload)
|
||||||
|
}
|
||||||
|
const first = item.payload.questions?.[0]?.question
|
||||||
|
return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question")
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryTitle = () => {
|
||||||
|
if (item.kind === "permission") {
|
||||||
|
return getPermissionKind(item.payload)
|
||||||
|
}
|
||||||
|
const count = item.payload.questions?.length ?? 0
|
||||||
|
return count === 1
|
||||||
|
? t("permissionApproval.questionCount.one", { count })
|
||||||
|
: t("permissionApproval.questionCount.other", { count })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div class="permission-center-item-header">
|
||||||
|
<div class="permission-center-item-heading">
|
||||||
|
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
|
||||||
|
<span class="permission-center-item-kind">{secondaryTitle()}</span>
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<span class="permission-center-item-chip">{t("permissionApproval.status.active")}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="permission-center-item-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="permission-center-item-action"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleGoToSession(sessionId())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("permissionApproval.actions.goToSession")}
|
||||||
|
</button>
|
||||||
|
<Show when={showFallback()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="permission-center-item-action"
|
||||||
|
disabled={loadingSession() === sessionId()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleLoadSession(sessionId())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingSession() === sessionId()
|
||||||
|
? t("permissionApproval.actions.loadingSession")
|
||||||
|
: t("permissionApproval.actions.loadSession")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={resolved()}
|
||||||
|
fallback={
|
||||||
|
<div class="permission-center-fallback">
|
||||||
|
<div class="permission-center-fallback-title">
|
||||||
|
<code>{primaryTitle()}</code>
|
||||||
|
</div>
|
||||||
|
<Show when={item.kind === "permission"}>
|
||||||
|
<div class="tool-call-permission-actions">
|
||||||
|
<div class="tool-call-permission-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
|
||||||
|
>
|
||||||
|
{t("permissionApproval.actions.allowOnce")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
|
||||||
|
>
|
||||||
|
{t("permissionApproval.actions.alwaysAllow")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-permission-button"
|
||||||
|
disabled={permissionSubmitting().has(item.id)}
|
||||||
|
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
|
||||||
|
>
|
||||||
|
{t("permissionApproval.actions.deny")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={permissionError().get(item.id)}>
|
||||||
|
{(err) => <div class="tool-call-permission-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.kind !== "permission"}>
|
||||||
|
<div class="permission-center-fallback-hint">{t("permissionApproval.fallbackHint")}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<ToolCall
|
||||||
|
toolCall={data().toolPart}
|
||||||
|
toolCallId={data().toolPart.id}
|
||||||
|
messageId={data().messageId}
|
||||||
|
messageVersion={data().messageVersion}
|
||||||
|
partVersion={data().partVersion}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={data().sessionId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionApprovalModal
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
|
import { ShieldAlert } from "lucide-solid"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
|
||||||
|
|
||||||
|
interface PermissionNotificationBannerProps {
|
||||||
|
instanceId: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
|
||||||
|
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
|
||||||
|
const queueLength = createMemo(() => permissionCount() + questionCount())
|
||||||
|
const hasRequests = createMemo(() => queueLength() > 0)
|
||||||
|
const label = createMemo(() => {
|
||||||
|
const total = queueLength()
|
||||||
|
|
||||||
|
const pendingLabel = total === 1
|
||||||
|
? t("permissionBanner.pendingRequests.one", { count: total })
|
||||||
|
: t("permissionBanner.pendingRequests.other", { count: total })
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (permissionCount() > 0) {
|
||||||
|
parts.push(
|
||||||
|
permissionCount() === 1
|
||||||
|
? t("permissionBanner.detail.permission.one", { count: permissionCount() })
|
||||||
|
: t("permissionBanner.detail.permission.other", { count: permissionCount() }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questionCount() > 0) {
|
||||||
|
parts.push(
|
||||||
|
questionCount() === 1
|
||||||
|
? t("permissionBanner.detail.question.one", { count: questionCount() })
|
||||||
|
: t("permissionBanner.detail.question.other", { count: questionCount() }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = parts.length ? t("permissionBanner.detail.wrapper", { detail: parts.join(", ") }) : ""
|
||||||
|
return `${pendingLabel}${detail}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={hasRequests()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="permission-center-trigger"
|
||||||
|
onClick={props.onClick}
|
||||||
|
aria-label={label()}
|
||||||
|
title={label()}
|
||||||
|
>
|
||||||
|
<ShieldAlert class="permission-center-icon" aria-hidden="true" />
|
||||||
|
<span class="permission-center-count" aria-hidden="true">
|
||||||
|
{queueLength() > 9 ? "9+" : queueLength()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionNotificationBanner
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
|
import ExpandButton from "./expand-button"
|
||||||
import { addToHistory, getHistory } from "../stores/message-history"
|
import { addToHistory, getHistory } from "../stores/message-history"
|
||||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
|
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
|
||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment } from "../types/attachment"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
|
||||||
|
import { getCommands } from "../stores/commands"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ interface PromptInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [prompt, setPromptInternal] = createSignal("")
|
const [prompt, setPromptInternal] = createSignal("")
|
||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
const HISTORY_LIMIT = 100
|
const HISTORY_LIMIT = 100
|
||||||
@@ -36,6 +41,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
const [showPicker, setShowPicker] = createSignal(false)
|
const [showPicker, setShowPicker] = createSignal(false)
|
||||||
|
const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention")
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||||
const [isDragging, setIsDragging] = createSignal(false)
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
@@ -43,9 +49,16 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||||
|
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
|
||||||
const SELECTION_INSERT_MAX_LENGTH = 2000
|
const SELECTION_INSERT_MAX_LENGTH = 2000
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
let containerRef: HTMLDivElement | undefined
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (mode() === "shell") {
|
||||||
|
return t("promptInput.placeholder.shell")
|
||||||
|
}
|
||||||
|
return t("promptInput.placeholder.default")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -560,14 +573,28 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const currentAttachments = attachments()
|
const currentAttachments = attachments()
|
||||||
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
||||||
|
|
||||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
|
||||||
const isShellMode = mode() === "shell"
|
const isShellMode = mode() === "shell"
|
||||||
|
|
||||||
|
// Slash command routing (match OpenCode TUI): only run if the command exists.
|
||||||
|
const isSlashCandidate = !isShellMode && text.startsWith("/")
|
||||||
|
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
|
||||||
|
const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : ""
|
||||||
|
const commandName = isSlashCandidate ? commandToken.slice(1) : ""
|
||||||
|
const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : ""
|
||||||
|
|
||||||
|
const isKnownSlashCommand =
|
||||||
|
isSlashCandidate &&
|
||||||
|
commandName.length > 0 &&
|
||||||
|
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||||
|
|
||||||
|
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
||||||
|
const historyEntry = resolvedPrompt
|
||||||
|
|
||||||
const refreshHistory = async () => {
|
const refreshHistory = async () => {
|
||||||
try {
|
try {
|
||||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
await addToHistory(props.instanceFolder, historyEntry)
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const next = [resolvedPrompt, ...prev]
|
const next = [historyEntry, ...prev]
|
||||||
if (next.length > HISTORY_LIMIT) {
|
if (next.length > HISTORY_LIMIT) {
|
||||||
next.length = HISTORY_LIMIT
|
next.length = HISTORY_LIMIT
|
||||||
}
|
}
|
||||||
@@ -579,13 +606,27 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setExpandState("normal")
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
clearAttachments(props.instanceId, props.sessionId)
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||||
setPasteCount(0)
|
if (!isKnownSlashCommand) {
|
||||||
setImageCount(0)
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
|
setPasteCount(0)
|
||||||
|
setImageCount(0)
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
} else {
|
||||||
|
syncAttachmentCounters("", currentAttachments)
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
}
|
||||||
|
|
||||||
setHistoryDraft(null)
|
setHistoryDraft(null)
|
||||||
|
|
||||||
|
if (isKnownSlashCommand) {
|
||||||
|
// Record attempted slash commands even if execution fails.
|
||||||
|
void refreshHistory()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isShellMode) {
|
if (isShellMode) {
|
||||||
if (props.onRunShell) {
|
if (props.onRunShell) {
|
||||||
@@ -593,14 +634,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
|
} else if (isKnownSlashCommand) {
|
||||||
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
void refreshHistory()
|
if (!isKnownSlashCommand) {
|
||||||
|
void refreshHistory()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to send message:", error)
|
log.error("Failed to send message:", error)
|
||||||
showAlertDialog("Failed to send message", {
|
showAlertDialog(t("promptInput.send.errorFallback"), {
|
||||||
title: "Send failed",
|
title: t("promptInput.send.errorTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
@@ -608,7 +653,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef?.focus()
|
textareaRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusTextareaEnd() {
|
function focusTextareaEnd() {
|
||||||
if (!textareaRef) return
|
if (!textareaRef) return
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -618,7 +663,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef.focus()
|
textareaRef.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseHistory(force = false) {
|
function canUseHistory(force = false) {
|
||||||
if (force) return true
|
if (force) return true
|
||||||
if (showPicker()) return false
|
if (showPicker()) return false
|
||||||
@@ -626,29 +671,29 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (!textarea) return false
|
if (!textarea) return false
|
||||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPreviousHistory(force = false) {
|
function selectPreviousHistory(force = false) {
|
||||||
const entries = history()
|
const entries = history()
|
||||||
if (entries.length === 0) return false
|
if (entries.length === 0) return false
|
||||||
if (!canUseHistory(force)) return false
|
if (!canUseHistory(force)) return false
|
||||||
|
|
||||||
if (historyIndex() === -1) {
|
if (historyIndex() === -1) {
|
||||||
setHistoryDraft(prompt())
|
setHistoryDraft(prompt())
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||||
setHistoryIndex(newIndex)
|
setHistoryIndex(newIndex)
|
||||||
setPrompt(entries[newIndex])
|
setPrompt(entries[newIndex])
|
||||||
focusTextareaEnd()
|
focusTextareaEnd()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNextHistory(force = false) {
|
function selectNextHistory(force = false) {
|
||||||
const entries = history()
|
const entries = history()
|
||||||
if (entries.length === 0) return false
|
if (entries.length === 0) return false
|
||||||
if (!canUseHistory(force)) return false
|
if (!canUseHistory(force)) return false
|
||||||
if (historyIndex() === -1) return false
|
if (historyIndex() === -1) return false
|
||||||
|
|
||||||
const newIndex = historyIndex() - 1
|
const newIndex = historyIndex() - 1
|
||||||
if (newIndex >= 0) {
|
if (newIndex >= 0) {
|
||||||
setHistoryIndex(newIndex)
|
setHistoryIndex(newIndex)
|
||||||
@@ -662,12 +707,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
focusTextareaEnd()
|
focusTextareaEnd()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAbort() {
|
function handleAbort() {
|
||||||
if (!props.onAbortSession || !props.isSessionBusy) return
|
if (!props.onAbortSession || !props.isSessionBusy) return
|
||||||
void props.onAbortSession()
|
void props.onAbortSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExpandToggle(nextState: "normal" | "expanded") {
|
||||||
|
setExpandState(nextState)
|
||||||
|
// Keep focus on textarea
|
||||||
|
textareaRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
@@ -677,11 +728,27 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setHistoryDraft(null)
|
setHistoryDraft(null)
|
||||||
|
|
||||||
const cursorPos = target.selectionStart
|
const cursorPos = target.selectionStart
|
||||||
|
|
||||||
|
// Slash command picker (only when editing the command token: "/<query>")
|
||||||
|
if (value.startsWith("/") && cursorPos >= 1) {
|
||||||
|
const firstWhitespaceIndex = value.slice(1).search(/\s/)
|
||||||
|
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
|
||||||
|
|
||||||
|
if (cursorPos <= tokenEnd) {
|
||||||
|
setPickerMode("command")
|
||||||
|
setAtPosition(0)
|
||||||
|
setSearchQuery(value.substring(1, cursorPos))
|
||||||
|
setShowPicker(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const textBeforeCursor = value.substring(0, cursorPos)
|
const textBeforeCursor = value.substring(0, cursorPos)
|
||||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
|
||||||
|
|
||||||
const previousAtPosition = atPosition()
|
const previousAtPosition = atPosition()
|
||||||
|
|
||||||
|
|
||||||
if (lastAtIndex === -1) {
|
if (lastAtIndex === -1) {
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
|
||||||
@@ -698,6 +765,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
|
||||||
if (!ignoredAtPositions().has(lastAtIndex)) {
|
if (!ignoredAtPositions().has(lastAtIndex)) {
|
||||||
|
setPickerMode("mention")
|
||||||
setAtPosition(lastAtIndex)
|
setAtPosition(lastAtIndex)
|
||||||
setSearchQuery(textAfterAt)
|
setSearchQuery(textAfterAt)
|
||||||
setShowPicker(true)
|
setShowPicker(true)
|
||||||
@@ -714,11 +782,32 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
item:
|
item:
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
| {
|
| {
|
||||||
type: "file"
|
type: "file"
|
||||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||||
},
|
}
|
||||||
|
| { type: "command"; command: SDKCommand },
|
||||||
) {
|
) {
|
||||||
if (item.type === "agent") {
|
if (item.type === "command") {
|
||||||
|
const name = item.command.name
|
||||||
|
const currentPrompt = prompt()
|
||||||
|
|
||||||
|
const afterSlash = currentPrompt.slice(1)
|
||||||
|
const firstWhitespaceIndex = afterSlash.search(/\s/)
|
||||||
|
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
|
||||||
|
|
||||||
|
const before = ""
|
||||||
|
const after = currentPrompt.substring(tokenEnd)
|
||||||
|
const newPrompt = before + `/${name} ` + after
|
||||||
|
setPrompt(newPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef) {
|
||||||
|
const newCursorPos = `/${name} `.length
|
||||||
|
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textareaRef.focus()
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} else if (item.type === "agent") {
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = attachments()
|
const existingAttachments = attachments()
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -757,7 +846,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const currentPrompt = prompt()
|
const currentPrompt = prompt()
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textareaRef?.selectionStart || 0
|
const cursorPos = textareaRef?.selectionStart || 0
|
||||||
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
const folderMention =
|
||||||
|
relativePath === "." || relativePath === ""
|
||||||
|
? "/"
|
||||||
|
: relativePath.replace(/\/+$/, "") + "/"
|
||||||
|
|
||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
@@ -801,7 +893,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
const before = currentPrompt.substring(0, pos)
|
const before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const attachmentText = `@${filename}`
|
const attachmentText = `@${normalizedPath}`
|
||||||
const newPrompt = before + attachmentText + " " + after
|
const newPrompt = before + attachmentText + " " + after
|
||||||
setPrompt(newPrompt)
|
setPrompt(newPrompt)
|
||||||
|
|
||||||
@@ -822,7 +914,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
function handlePickerClose() {
|
function handlePickerClose() {
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
@@ -946,19 +1038,23 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
||||||
|
|
||||||
const hasHistory = () => history().length > 0
|
const hasHistory = () => history().length > 0
|
||||||
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
||||||
const canHistoryGoNext = () => historyIndex() >= 0
|
const canHistoryGoNext = () => historyIndex() >= 0
|
||||||
|
|
||||||
const canSend = () => {
|
const canSend = () => {
|
||||||
if (props.disabled) return false
|
if (props.disabled) return false
|
||||||
const hasText = prompt().trim().length > 0
|
const hasText = prompt().trim().length > 0
|
||||||
if (mode() === "shell") return hasText
|
if (mode() === "shell") return hasText
|
||||||
return hasText || attachments().length > 0
|
return hasText || attachments().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
const shellHint = () =>
|
||||||
|
mode() === "shell"
|
||||||
|
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||||
|
: { key: "!", text: t("promptInput.hints.shell.enable") }
|
||||||
|
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
|
||||||
|
|
||||||
const shouldShowOverlay = () => prompt().length === 0
|
const shouldShowOverlay = () => prompt().length === 0
|
||||||
|
|
||||||
@@ -967,7 +1063,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
|
||||||
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
||||||
style={
|
style={
|
||||||
isDragging()
|
isDragging()
|
||||||
@@ -981,9 +1076,11 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
<Show when={showPicker() && instance()}>
|
<Show when={showPicker() && instance()}>
|
||||||
<UnifiedPicker
|
<UnifiedPicker
|
||||||
open={showPicker()}
|
open={showPicker()}
|
||||||
|
mode={pickerMode()}
|
||||||
onClose={handlePickerClose}
|
onClose={handlePickerClose}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
agents={instanceAgents()}
|
agents={instanceAgents()}
|
||||||
|
commands={getCommands(props.instanceId)}
|
||||||
instanceClient={instance()!.client}
|
instanceClient={instance()!.client}
|
||||||
searchQuery={searchQuery()}
|
searchQuery={searchQuery()}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
@@ -992,183 +1089,92 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col">
|
<div class="flex flex-1 flex-col">
|
||||||
<Show when={attachments().length > 0}>
|
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||||
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
|
|
||||||
<For each={attachments()}>
|
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
|
||||||
{(attachment) => {
|
|
||||||
const isImage = attachment.mediaType.startsWith("image/")
|
|
||||||
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
|
|
||||||
const isTextAttachment = typeof textValue === "string"
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
|
||||||
title={textValue}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={isImage}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={isTextAttachment}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={attachment.source.type === "agent"}
|
|
||||||
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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
|
|
||||||
</Show>
|
|
||||||
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
|
|
||||||
<Show when={isTextAttachment}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleExpandTextAttachment(attachment)}
|
|
||||||
class="attachment-expand"
|
|
||||||
aria-label="Expand pasted text"
|
|
||||||
title="Insert pasted text"
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
|
||||||
class="attachment-remove"
|
|
||||||
aria-label="Remove attachment"
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<Show when={isImage}>
|
|
||||||
<div class="attachment-chip-preview">
|
|
||||||
<img src={attachment.url} alt={attachment.filename} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="prompt-input-field-container">
|
|
||||||
<div class="prompt-input-field">
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||||
placeholder={
|
placeholder={getPlaceholder()}
|
||||||
mode() === "shell"
|
value={prompt()}
|
||||||
? "Run a shell command (Esc to exit)..."
|
onInput={handleInput}
|
||||||
: "Type your message, @file, @agent, or paste images and text..."
|
onKeyDown={handleKeyDown}
|
||||||
}
|
onPaste={handlePaste}
|
||||||
value={prompt()}
|
onFocus={() => setIsFocused(true)}
|
||||||
onInput={handleInput}
|
onBlur={() => setIsFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
disabled={props.disabled}
|
||||||
onPaste={handlePaste}
|
rows={expandState() === "expanded" ? 15 : 4}
|
||||||
onFocus={() => setIsFocused(true)}
|
spellcheck={false}
|
||||||
onBlur={() => setIsFocused(false)}
|
autocorrect="off"
|
||||||
disabled={props.disabled}
|
autoCapitalize="off"
|
||||||
rows={4}
|
autocomplete="off"
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
/>
|
||||||
spellcheck={false}
|
<div class="prompt-nav-buttons">
|
||||||
autocorrect="off"
|
<ExpandButton
|
||||||
autoCapitalize="off"
|
expandState={expandState}
|
||||||
autocomplete="off"
|
onToggleExpand={handleExpandToggle}
|
||||||
/>
|
/>
|
||||||
<Show when={hasHistory()}>
|
<Show when={hasHistory()}>
|
||||||
<div class="prompt-history-top">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="prompt-history-button"
|
||||||
class="prompt-history-button"
|
onClick={() => selectPreviousHistory(true)}
|
||||||
onClick={() => selectPreviousHistory(true)}
|
disabled={!canHistoryGoPrevious()}
|
||||||
disabled={!canHistoryGoPrevious()}
|
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||||
aria-label="Previous prompt"
|
>
|
||||||
>
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div class="prompt-history-bottom">
|
class="prompt-history-button"
|
||||||
<button
|
onClick={() => selectNextHistory(true)}
|
||||||
type="button"
|
disabled={!canHistoryGoNext()}
|
||||||
class="prompt-history-button"
|
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||||
onClick={() => selectNextHistory(true)}
|
>
|
||||||
disabled={!canHistoryGoNext()}
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
aria-label="Next prompt"
|
</button>
|
||||||
>
|
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={shouldShowOverlay()}>
|
|
||||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
|
||||||
<Show
|
|
||||||
when={props.escapeInDebounce}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<span class="prompt-overlay-text">
|
|
||||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
|
||||||
</span>
|
|
||||||
<Show when={attachments().length > 0}>
|
|
||||||
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
|
||||||
</Show>
|
|
||||||
<span class="prompt-overlay-text">
|
|
||||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
|
||||||
</span>
|
|
||||||
<Show when={mode() === "shell"}>
|
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
|
||||||
Press <Kbd>Esc</Kbd> again to abort session
|
|
||||||
</span>
|
|
||||||
<Show when={mode() === "shell"}>
|
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<Show when={shouldShowOverlay()}>
|
||||||
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
|
<Show
|
||||||
|
when={props.escapeInDebounce}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} • <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")} • <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} • <Kbd>↑↓</Kbd> {t("promptInput.overlay.history")}
|
||||||
|
</span>
|
||||||
|
<Show when={attachments().length > 0}>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
|
||||||
|
</Show>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||||
|
</span>
|
||||||
|
<Show when={mode() !== "shell"}>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{commandHint().key}</Kbd> {commandHint().text}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={mode() === "shell"}>
|
||||||
|
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||||
|
{t("promptInput.overlay.press")} <Kbd>Esc</Kbd> {t("promptInput.overlay.againToAbort")}
|
||||||
|
</span>
|
||||||
|
<Show when={mode() === "shell"}>
|
||||||
|
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
<button
|
<button
|
||||||
@@ -1176,8 +1182,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
class="stop-button"
|
class="stop-button"
|
||||||
onClick={handleAbort}
|
onClick={handleAbort}
|
||||||
disabled={!canStop()}
|
disabled={!canStop()}
|
||||||
aria-label="Stop session"
|
aria-label={t("promptInput.stopSession.ariaLabel")}
|
||||||
title="Stop session"
|
title={t("promptInput.stopSession.title")}
|
||||||
>
|
>
|
||||||
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
<rect x="4" y="4" width="12" height="12" rx="2" />
|
<rect x="4" y="4" width="12" height="12" rx="2" />
|
||||||
@@ -1188,7 +1194,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!canSend()}
|
disabled={!canSend()}
|
||||||
aria-label="Send message"
|
aria-label={t("promptInput.send.ariaLabel")}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={mode() === "shell"}
|
when={mode() === "shell"}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { restartCli } from "../lib/native/cli"
|
|||||||
import { preferences, setListeningMode } from "../stores/preferences"
|
import { preferences, setListeningMode } from "../stores/preferences"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +19,18 @@ interface RemoteAccessOverlayProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||||
|
const [passwordValue, setPasswordValue] = createSignal("")
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||||
@@ -38,9 +46,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setPasswordError(null)
|
||||||
try {
|
try {
|
||||||
const result = await serverApi.fetchServerMeta()
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(result)
|
setMeta(metaResult)
|
||||||
|
setAuthStatus(authResult)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,11 +87,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
title: allow ? "Open to other devices" : "Limit to this device",
|
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: "Restart now",
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
@@ -92,7 +102,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
setListeningMode(targetMode)
|
setListeningMode(targetMode)
|
||||||
const restarted = await restartCli()
|
const restarted = await restartCli()
|
||||||
if (!restarted) {
|
if (!restarted) {
|
||||||
setError("Unable to restart automatically. Please restart the app to apply the change.")
|
setError(t("remoteAccess.restart.errorManual"))
|
||||||
} else {
|
} else {
|
||||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmitPassword = async () => {
|
||||||
|
setPasswordError(null)
|
||||||
|
|
||||||
|
const next = passwordValue()
|
||||||
|
const confirm = passwordConfirm()
|
||||||
|
|
||||||
|
if (next.trim().length < 8) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPassword(true)
|
||||||
|
try {
|
||||||
|
const result = await serverApi.setServerPassword(next)
|
||||||
|
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
|
||||||
|
setPasswordValue("")
|
||||||
|
setPasswordConfirm("")
|
||||||
|
setPasswordFormOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={props.open}
|
open={props.open}
|
||||||
@@ -124,11 +164,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
||||||
<header class="remote-header">
|
<header class="remote-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-eyebrow">Remote handover</p>
|
<p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
|
||||||
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
|
<h2 class="remote-title">{t("remoteAccess.title")}</h2>
|
||||||
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
|
<p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
|
<button type="button" class="remote-close" onClick={props.onClose} aria-label={t("remoteAccess.close")}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -139,13 +179,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Shield class="remote-icon" />
|
<Shield class="remote-icon" />
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-label">Listening mode</p>
|
<p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
|
||||||
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
|
<p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
||||||
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
||||||
<span class="remote-refresh-label">Refresh</span>
|
<span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,54 +198,142 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
>
|
>
|
||||||
<Switch.Input />
|
<Switch.Input />
|
||||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||||
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
|
<span class="remote-toggle-state">{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}</span>
|
||||||
<Switch.Thumb class="remote-toggle-thumb" />
|
<Switch.Thumb class="remote-toggle-thumb" />
|
||||||
</Switch.Control>
|
</Switch.Control>
|
||||||
<div class="remote-toggle-copy">
|
<div class="remote-toggle-copy">
|
||||||
<span class="remote-toggle-title">Allow connections from other IPs</span>
|
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
||||||
<span class="remote-toggle-caption">
|
<span class="remote-toggle-caption">
|
||||||
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
|
{allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Switch>
|
</Switch>
|
||||||
<p class="remote-toggle-note">
|
<p class="remote-toggle-note">
|
||||||
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
|
{t("remoteAccess.toggle.note")}
|
||||||
server restarts.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="remote-section">
|
<section class="remote-section">
|
||||||
<div class="remote-section-heading">
|
<div class="remote-section-heading">
|
||||||
<div class="remote-section-title">
|
<div class="remote-section-title">
|
||||||
<Wifi class="remote-icon" />
|
<Shield class="remote-icon" />
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-label">Reachable addresses</p>
|
<p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
|
||||||
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
|
<p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses…</div>}>
|
<Show
|
||||||
|
when={authStatus() && authStatus()!.authenticated}
|
||||||
|
fallback={<div class="remote-card">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
|
>
|
||||||
|
<div class="remote-card">
|
||||||
|
<p class="remote-help">
|
||||||
|
{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}
|
||||||
|
</p>
|
||||||
|
<p class="remote-help">
|
||||||
|
{authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.status.set")
|
||||||
|
: t("remoteAccess.password.status.unset")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
|
setPasswordError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{passwordFormOpen()
|
||||||
|
? t("remoteAccess.password.actions.cancel")
|
||||||
|
: authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.actions.change")
|
||||||
|
: t("remoteAccess.password.actions.set")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordFormOpen()}>
|
||||||
|
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
|
||||||
|
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordValue()}
|
||||||
|
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||||
|
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
|
||||||
|
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.confirmPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm()}
|
||||||
|
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordError()}>
|
||||||
|
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
disabled={savingPassword()}
|
||||||
|
onClick={() => void handleSubmitPassword()}
|
||||||
|
>
|
||||||
|
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="remote-section">
|
||||||
|
|
||||||
|
<div class="remote-section-heading">
|
||||||
|
<div class="remote-section-title">
|
||||||
|
<Wifi class="remote-icon" />
|
||||||
|
<div>
|
||||||
|
<p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
|
||||||
|
<p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
|
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<For each={displayAddresses()}>
|
<For each={displayAddresses()}>
|
||||||
{(address) => {
|
{(address) => {
|
||||||
const expandedState = () => expandedUrl() === address.url
|
const expandedState = () => expandedUrl() === address.url
|
||||||
const qr = () => qrCodes()[address.url]
|
const qr = () => qrCodes()[address.url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{address.url}</p>
|
<p class="remote-address-url">{address.url}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">
|
||||||
{address.family.toUpperCase()} • {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} • {address.ip}
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||||
<ExternalLink class="remote-icon" />
|
<ExternalLink class="remote-icon" />
|
||||||
Open
|
{t("remoteAccess.address.open")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="remote-pill"
|
class="remote-pill"
|
||||||
@@ -214,14 +342,20 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
aria-expanded={expandedState()}
|
aria-expanded={expandedState()}
|
||||||
>
|
>
|
||||||
<Link2 class="remote-icon" />
|
<Link2 class="remote-icon" />
|
||||||
{expandedState() ? "Hide QR" : "Show QR"}
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={expandedState()}>
|
<Show when={expandedState()}>
|
||||||
<div class="remote-qr">
|
<div class="remote-qr">
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />}
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
|
||||||
import type { Session, SessionStatus } from "../types/session"
|
import type { SessionStatus } from "../types/session"
|
||||||
|
import type { SessionThread } from "../stores/session-state"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
import { getSessionStatus } from "../stores/session-status"
|
||||||
import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
|
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import Kbd from "./kbd"
|
|
||||||
import SessionRenameDialog from "./session-rename-dialog"
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { formatShortcut } from "../lib/keyboard-utils"
|
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import {
|
||||||
|
deleteSession,
|
||||||
|
ensureSessionParentExpanded,
|
||||||
|
getVisibleSessionIds,
|
||||||
|
isSessionParentExpanded,
|
||||||
|
loading,
|
||||||
|
renameSession,
|
||||||
|
sessions as sessionStateSessions,
|
||||||
|
setActiveSessionFromList,
|
||||||
|
toggleSessionParentExpanded,
|
||||||
|
} from "../stores/sessions"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -17,10 +27,9 @@ const log = getLogger("session")
|
|||||||
|
|
||||||
interface SessionListProps {
|
interface SessionListProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessions: Map<string, Session>
|
threads: SessionThread[]
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
onSelect: (sessionId: string) => void
|
onSelect: (sessionId: string) => void
|
||||||
onClose: (sessionId: string) => void
|
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
showHeader?: boolean
|
showHeader?: boolean
|
||||||
showFooter?: boolean
|
showFooter?: boolean
|
||||||
@@ -29,45 +38,27 @@ interface SessionListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionStatus(status: SessionStatus): string {
|
function formatSessionStatus(status: SessionStatus): string {
|
||||||
switch (status) {
|
return status
|
||||||
case "working":
|
|
||||||
return "Working"
|
|
||||||
case "compacting":
|
|
||||||
return "Compacting"
|
|
||||||
default:
|
|
||||||
return "Idle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
|
|
||||||
if (!prev) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.length !== next.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < prev.length; i++) {
|
|
||||||
if (prev[i] !== next[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionList: Component<SessionListProps> = (props) => {
|
const SessionList: Component<SessionListProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
|
||||||
|
|
||||||
const isSessionDeleting = (sessionId: string) => {
|
const isSessionDeleting = (sessionId: string) => {
|
||||||
const deleting = loading().deletingSession.get(props.instanceId)
|
const deleting = loading().deletingSession.get(props.instanceId)
|
||||||
return deleting ? deleting.has(sessionId) : false
|
return deleting ? deleting.has(sessionId) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const selectSession = (sessionId: string) => {
|
const selectSession = (sessionId: string) => {
|
||||||
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
|
const parentId = session?.parentId ?? session?.id
|
||||||
|
if (parentId) {
|
||||||
|
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||||
|
}
|
||||||
|
|
||||||
props.onSelect(sessionId)
|
props.onSelect(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,30 +68,66 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
try {
|
try {
|
||||||
const success = await copyToClipboard(sessionId)
|
const success = await copyToClipboard(sessionId)
|
||||||
if (success) {
|
if (success) {
|
||||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" })
|
||||||
} else {
|
} else {
|
||||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
|
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (isSessionDeleting(sessionId)) return
|
if (isSessionDeleting(sessionId)) return
|
||||||
|
|
||||||
|
const shouldSelectFallback = props.activeSessionId === sessionId
|
||||||
|
let fallbackSessionId: string | undefined
|
||||||
|
|
||||||
|
if (shouldSelectFallback) {
|
||||||
|
const visible = getVisibleSessionIds(props.instanceId)
|
||||||
|
const currentIndex = visible.indexOf(sessionId)
|
||||||
|
const remaining = visible.filter((id) => id !== sessionId)
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
for (let i = currentIndex; i < visible.length; i++) {
|
||||||
|
const candidate = visible[i]
|
||||||
|
if (candidate && candidate !== sessionId) {
|
||||||
|
fallbackSessionId = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fallbackSessionId) {
|
||||||
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
|
const candidate = visible[i]
|
||||||
|
if (candidate && candidate !== sessionId) {
|
||||||
|
fallbackSessionId = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackSessionId ??= remaining[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteSession(props.instanceId, sessionId)
|
await deleteSession(props.instanceId, sessionId)
|
||||||
|
if (fallbackSessionId) {
|
||||||
|
setActiveSessionFromList(props.instanceId, fallbackSessionId)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
showToastNotification({ message: t("sessionList.delete.error"), variant: "error" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRenameDialog = (sessionId: string) => {
|
const openRenameDialog = (sessionId: string) => {
|
||||||
const session = props.sessions.get(sessionId)
|
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
const label = session.title && session.title.trim() ? session.title : sessionId
|
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||||
@@ -120,73 +147,110 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
setRenameTarget(null)
|
setRenameTarget(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to rename session ${target.id}:`, error)
|
log.error(`Failed to rename session ${target.id}:`, error)
|
||||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
showToastNotification({ message: t("sessionList.rename.error"), variant: "error" })
|
||||||
} finally {
|
} finally {
|
||||||
setIsRenaming(false)
|
setIsRenaming(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
const SessionRow: Component<{
|
||||||
const session = () => props.sessions.get(rowProps.sessionId)
|
sessionId: string
|
||||||
|
isChild?: boolean
|
||||||
|
isLastChild?: boolean
|
||||||
|
hasChildren?: boolean
|
||||||
|
expanded?: boolean
|
||||||
|
onToggleExpand?: () => void
|
||||||
|
}> = (rowProps) => {
|
||||||
|
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
|
||||||
if (!session()) {
|
if (!session()) {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||||
const title = () => session()?.title || "Untitled"
|
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||||
const statusLabel = () => formatSessionStatus(status())
|
const statusLabel = () => {
|
||||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
switch (formatSessionStatus(status())) {
|
||||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
case "working":
|
||||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
return t("sessionList.status.working")
|
||||||
|
case "compacting":
|
||||||
|
return t("sessionList.status.compacting")
|
||||||
|
default:
|
||||||
|
return t("sessionList.status.idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||||
|
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||||
|
const needsInput = () => needsPermission() || needsQuestion()
|
||||||
|
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
|
||||||
|
const statusText = () =>
|
||||||
|
needsPermission()
|
||||||
|
? t("sessionList.status.needsPermission")
|
||||||
|
: needsQuestion()
|
||||||
|
? t("sessionList.status.needsInput")
|
||||||
|
: statusLabel()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-list-item group">
|
<div class="session-list-item group">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||||
|
data-session-id={rowProps.sessionId}
|
||||||
onClick={() => selectSession(rowProps.sessionId)}
|
onClick={() => selectSession(rowProps.sessionId)}
|
||||||
title={title()}
|
title={title()}
|
||||||
role="button"
|
role="button"
|
||||||
aria-selected={isActive()}
|
aria-selected={isActive()}
|
||||||
|
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
|
||||||
>
|
>
|
||||||
<div class="session-item-row session-item-header">
|
<div class="session-item-row session-item-header">
|
||||||
<div class="session-item-title-row">
|
<div class="session-item-title-row">
|
||||||
<MessageSquare class="w-4 h-4 flex-shrink-0" />
|
{rowProps.isChild ? (
|
||||||
<span class="session-item-title truncate">{title()}</span>
|
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<User class="w-4 h-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={rowProps.canClose}>
|
|
||||||
<span
|
|
||||||
class="session-item-close opacity-80 hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
props.onClose(rowProps.sessionId)
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Close session"
|
|
||||||
>
|
|
||||||
<X class="w-3 h-3" />
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-row session-item-meta">
|
<div class="session-item-row session-item-meta">
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
{pendingPermission() ? (
|
<Show
|
||||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
when={rowProps.hasChildren && !rowProps.isChild}
|
||||||
) : (
|
fallback={
|
||||||
<span class="status-dot" />
|
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
|
||||||
)}
|
}
|
||||||
{statusText()}
|
>
|
||||||
</span>
|
<span
|
||||||
|
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
rowProps.onToggleExpand?.()
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
|
||||||
|
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
|
||||||
|
>
|
||||||
|
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||||
|
{needsInput() ? (
|
||||||
|
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<span class="status-dot" />
|
||||||
|
)}
|
||||||
|
{statusText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
<span
|
<span
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
onClick={(event) => copySessionId(event, rowProps.sessionId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Copy session ID"
|
aria-label={t("sessionList.actions.copyId.ariaLabel")}
|
||||||
title="Copy session ID"
|
title={t("sessionList.actions.copyId.title")}
|
||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -198,8 +262,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Rename session"
|
aria-label={t("sessionList.actions.rename.ariaLabel")}
|
||||||
title="Rename session"
|
title={t("sessionList.actions.rename.title")}
|
||||||
>
|
>
|
||||||
<Pencil class="w-3 h-3" />
|
<Pencil class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -208,8 +272,8 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="Delete session"
|
aria-label={t("sessionList.actions.delete.ariaLabel")}
|
||||||
title="Delete session"
|
title={t("sessionList.actions.delete.title")}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={!isSessionDeleting(rowProps.sessionId)}
|
when={!isSessionDeleting(rowProps.sessionId)}
|
||||||
@@ -234,38 +298,69 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userSessionIds = createMemo(
|
const activeParentId = createMemo(() => {
|
||||||
() => {
|
const activeId = props.activeSessionId
|
||||||
const ids: string[] = []
|
if (!activeId || activeId === "info") return null
|
||||||
for (const session of props.sessions.values()) {
|
|
||||||
if (session.parentId === null) {
|
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||||
ids.push(session.id)
|
if (!activeSession) return null
|
||||||
}
|
|
||||||
}
|
return activeSession.parentId ?? activeSession.id
|
||||||
return ids
|
})
|
||||||
},
|
|
||||||
undefined,
|
createEffect(() => {
|
||||||
{ equals: arraysEqual },
|
const parentId = activeParentId()
|
||||||
)
|
if (!parentId) return
|
||||||
|
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||||
const childSessionIds = createMemo(
|
})
|
||||||
() => {
|
|
||||||
const children: { id: string; updated: number }[] = []
|
|
||||||
for (const session of props.sessions.values()) {
|
|
||||||
if (session.parentId !== null) {
|
|
||||||
children.push({ id: session.id, updated: session.time.updated ?? 0 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (children.length <= 1) {
|
|
||||||
return children.map((entry) => entry.id)
|
|
||||||
}
|
|
||||||
children.sort((a, b) => b.updated - a.updated)
|
|
||||||
return children.map((entry) => entry.id)
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ equals: arraysEqual },
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const listEl = createSignal<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const escapeCss = (value: string) => {
|
||||||
|
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
|
||||||
|
return (CSS as any).escape(value)
|
||||||
|
}
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollActiveIntoView = (sessionId: string) => {
|
||||||
|
const root = listEl[0]()
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const selector = `[data-session-id="${escapeCss(sessionId)}"]`
|
||||||
|
|
||||||
|
const scrollNow = () => {
|
||||||
|
const target = root.querySelector(selector) as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
target.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof requestAnimationFrame === "undefined") {
|
||||||
|
scrollNow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a couple frames so expand/collapse DOM settles.
|
||||||
|
let raf1 = 0
|
||||||
|
let raf2 = 0
|
||||||
|
raf1 = requestAnimationFrame(() => {
|
||||||
|
raf2 = requestAnimationFrame(() => {
|
||||||
|
scrollNow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (raf1) cancelAnimationFrame(raf1)
|
||||||
|
if (raf2) cancelAnimationFrame(raf2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeId = props.activeSessionId
|
||||||
|
if (!activeId || activeId === "info") return
|
||||||
|
scrollActiveIntoView(activeId)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||||
@@ -274,7 +369,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-list-header p-3 border-b border-base">
|
<div class="session-list-header p-3 border-b border-base">
|
||||||
{props.headerContent ?? (
|
{props.headerContent ?? (
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
|
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||||
/>
|
/>
|
||||||
@@ -283,46 +378,34 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="session-list flex-1 overflow-y-auto">
|
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||||
<div class="session-section">
|
|
||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
|
||||||
Instance
|
|
||||||
</div>
|
|
||||||
<div class="session-list-item group">
|
|
||||||
<button
|
|
||||||
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
|
|
||||||
onClick={() => selectSession("info")}
|
|
||||||
title="Instance Info"
|
|
||||||
role="button"
|
|
||||||
aria-selected={props.activeSessionId === "info"}
|
|
||||||
>
|
|
||||||
<div class="session-item-row session-item-header">
|
|
||||||
<div class="session-item-title-row">
|
|
||||||
<Info class="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span class="session-item-title truncate">Instance Info</span>
|
|
||||||
</div>
|
|
||||||
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Show when={props.threads.length > 0}>
|
||||||
|
<div class="session-section">
|
||||||
|
<For each={props.threads}>
|
||||||
|
|
||||||
<Show when={userSessionIds().length > 0}>
|
{(thread) => {
|
||||||
<div class="session-section">
|
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
|
||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
return (
|
||||||
User Session
|
<>
|
||||||
</div>
|
<SessionRow
|
||||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
sessionId={thread.parent.id}
|
||||||
</div>
|
hasChildren={thread.children.length > 0}
|
||||||
</Show>
|
expanded={expanded()}
|
||||||
|
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Show when={childSessionIds().length > 0}>
|
<Show when={expanded() && thread.children.length > 0}>
|
||||||
<div class="session-section">
|
<For each={thread.children}>
|
||||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
{(child, index) => (
|
||||||
Agent Sessions
|
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||||
</div>
|
)}
|
||||||
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
|
</For>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,4 +429,3 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SessionList
|
export default SessionList
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getParentSessions, createSession, setActiveParentSession } from "../sto
|
|||||||
import { instances, stopInstance } from "../stores/instances"
|
import { instances, stopInstance } from "../stores/instances"
|
||||||
import { agents } from "../stores/sessions"
|
import { agents } from "../stores/sessions"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface SessionPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
|
||||||
const [isCreating, setIsCreating] = createSignal(false)
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
|
|
||||||
@@ -40,10 +42,10 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`
|
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
|
||||||
if (hours > 0) return `${hours}h ago`
|
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
|
||||||
if (minutes > 0) return `${minutes}m ago`
|
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
|
||||||
return "just now"
|
return t("time.relative.justNow")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSessionSelect(sessionId: string) {
|
async function handleSessionSelect(sessionId: string) {
|
||||||
@@ -74,19 +76,19 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
|
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
|
||||||
OpenCode • {instance()?.folder.split("/").pop()}
|
{t("sessionPicker.title", { folder: instance()?.folder.split("/").pop() })}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Show
|
<Show
|
||||||
when={parentSessions().length > 0}
|
when={parentSessions().length > 0}
|
||||||
fallback={<div class="text-center py-4 text-sm text-muted">No previous sessions</div>}
|
fallback={<div class="text-center py-4 text-sm text-muted">{t("sessionPicker.empty.noPrevious")}</div>}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-secondary mb-2">
|
<h3 class="text-sm font-medium text-secondary mb-2">
|
||||||
Resume a session ({parentSessions().length}):
|
{t("sessionPicker.resume.title", { count: parentSessions().length })}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
<div class="space-y-1 max-h-[400px] overflow-y-auto">
|
||||||
<For each={parentSessions()}>
|
<For each={parentSessions()}>
|
||||||
@@ -98,7 +100,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="selector-option-content w-full">
|
<div class="selector-option-content w-full">
|
||||||
<span class="selector-option-label truncate">
|
<span class="selector-option-label truncate">
|
||||||
{session.title || "Untitled"}
|
{session.title || t("sessionPicker.session.untitled")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="selector-badge-time flex-shrink-0">
|
<span class="selector-badge-time flex-shrink-0">
|
||||||
@@ -116,16 +118,16 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
<div class="w-full border-t border-base" />
|
<div class="w-full border-t border-base" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex justify-center text-sm">
|
<div class="relative flex justify-center text-sm">
|
||||||
<span class="px-2 bg-surface-base text-muted">or</span>
|
<span class="px-2 bg-surface-base text-muted">{t("sessionPicker.divider.or")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-secondary mb-2">Start new session:</h3>
|
<h3 class="text-sm font-medium text-secondary mb-2">{t("sessionPicker.new.title")}</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<Show
|
<Show
|
||||||
when={agentList().length > 0}
|
when={agentList().length > 0}
|
||||||
fallback={<div class="text-sm text-muted">Loading agents...</div>}
|
fallback={<div class="text-sm text-muted">{t("sessionPicker.agents.loading")}</div>}
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
class="selector-input w-full"
|
class="selector-input w-full"
|
||||||
@@ -161,9 +163,13 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!isCreating()}
|
when={!isCreating()}
|
||||||
fallback={<span>Creating...</span>}
|
fallback={<span>{t("sessionPicker.actions.creating")}</span>}
|
||||||
>
|
>
|
||||||
<span>{agentList().length === 0 ? "Loading agents..." : "Create Session"}</span>
|
<span>
|
||||||
|
{agentList().length === 0
|
||||||
|
? t("sessionPicker.agents.loading")
|
||||||
|
: t("sessionPicker.actions.createSession")}
|
||||||
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2">
|
<kbd class="kbd ml-2">
|
||||||
@@ -180,7 +186,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("sessionPicker.actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
interface SessionRenameDialogProps {
|
interface SessionRenameDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +12,7 @@ interface SessionRenameDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const [title, setTitle] = createSignal("")
|
const [title, setTitle] = createSignal("")
|
||||||
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||||
let inputRef: HTMLInputElement | undefined
|
let inputRef: HTMLInputElement | undefined
|
||||||
@@ -40,9 +42,9 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
|
|
||||||
const description = () => {
|
const description = () => {
|
||||||
if (props.sessionLabel && props.sessionLabel.trim()) {
|
if (props.sessionLabel && props.sessionLabel.trim()) {
|
||||||
return `Update the title for "${props.sessionLabel}".`
|
return t("sessionRenameDialog.description.withLabel", { label: props.sessionLabel })
|
||||||
}
|
}
|
||||||
return "Set a new title for this session."
|
return t("sessionRenameDialog.description.default")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +60,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
||||||
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
|
<Dialog.Title class="text-lg font-semibold text-primary">{t("sessionRenameDialog.title")}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||||
{description()}
|
{description()}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
@@ -66,7 +68,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-medium text-secondary" for={inputId}>
|
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||||
Session name
|
{t("sessionRenameDialog.input.label")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
@@ -76,7 +78,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||||
placeholder="Enter a session name"
|
placeholder={t("sessionRenameDialog.input.placeholder")}
|
||||||
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +94,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
disabled={isSubmitting()}
|
disabled={isSubmitting()}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("sessionRenameDialog.actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -111,11 +113,11 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
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"
|
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>
|
</svg>
|
||||||
<span>Renaming…</span>
|
<span>{t("sessionRenameDialog.actions.renaming")}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Rename
|
{t("sessionRenameDialog.actions.rename")}
|
||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMemo, type Component } from "solid-js"
|
import { createMemo, type Component } from "solid-js"
|
||||||
import { getSessionInfo } from "../../stores/sessions"
|
import { getSessionInfo } from "../../stores/sessions"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
interface ContextUsagePanelProps {
|
interface ContextUsagePanelProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -12,6 +13,7 @@ const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
|||||||
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||||
|
|
||||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const info = createMemo(
|
const info = createMemo(
|
||||||
() =>
|
() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
@@ -39,7 +41,7 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
const formatTokenValue = (value: number | null | undefined) => {
|
const formatTokenValue = (value: number | null | undefined) => {
|
||||||
if (value === null || value === undefined) return "--"
|
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
|
||||||
return formatTokenTotal(value)
|
return formatTokenTotal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,29 +50,29 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div class={headingClass}>Tokens</div>
|
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Input</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
|
||||||
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Output</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.output")}</span>
|
||||||
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Cost</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
|
||||||
<span class="font-semibold text-primary">{costDisplay()}</span>
|
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div class={headingClass}>Context</div>
|
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Used</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
|
||||||
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class={chipClass}>
|
<div class={chipClass}>
|
||||||
<span class={chipLabelClass}>Avail</span>
|
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
|
||||||
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||||
|
import { Expand } from "lucide-solid"
|
||||||
import type { Session } from "../../types/session"
|
import type { Session } from "../../types/session"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
import MessageSection from "../message-section"
|
import MessageSection from "../message-section"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import PromptInput from "../prompt-input"
|
import PromptInput from "../prompt-input"
|
||||||
|
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||||
|
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { requestData } from "../../lib/opencode-api"
|
import { requestData } from "../../lib/opencode-api"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ interface SessionViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
@@ -39,6 +44,62 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
if (!currentSession) return false
|
if (!currentSession) return false
|
||||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionNeedsInput = createMemo(() => {
|
||||||
|
const currentSession = session()
|
||||||
|
if (!currentSession) return false
|
||||||
|
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||||
|
|
||||||
|
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||||
|
if (attachment.source.type !== "text") return
|
||||||
|
|
||||||
|
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||||
|
const value = attachment.source.value
|
||||||
|
const match = attachment.display.match(/pasted #(\d+)/)
|
||||||
|
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||||
|
|
||||||
|
const currentText = textarea?.value ?? ""
|
||||||
|
|
||||||
|
let nextText = currentText
|
||||||
|
let selectionTarget: number | null = null
|
||||||
|
|
||||||
|
if (placeholder) {
|
||||||
|
const placeholderIndex = currentText.indexOf(placeholder)
|
||||||
|
if (placeholderIndex !== -1) {
|
||||||
|
nextText =
|
||||||
|
currentText.substring(0, placeholderIndex) +
|
||||||
|
value +
|
||||||
|
currentText.substring(placeholderIndex + placeholder.length)
|
||||||
|
selectionTarget = placeholderIndex + value.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextText === currentText) {
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||||
|
selectionTarget = start + value.length
|
||||||
|
} else {
|
||||||
|
nextText = currentText + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = nextText
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||||
|
textarea.focus()
|
||||||
|
if (selectionTarget !== null) {
|
||||||
|
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
function scheduleScrollToBottom() {
|
function scheduleScrollToBottom() {
|
||||||
@@ -93,8 +154,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to abort session", error)
|
log.error("Failed to abort session", error)
|
||||||
showAlertDialog("Failed to stop session", {
|
showAlertDialog(t("sessionView.alerts.abortFailed.message"), {
|
||||||
title: "Stop failed",
|
title: t("sessionView.alerts.abortFailed.title"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
@@ -142,8 +203,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to revert message", error)
|
log.error("Failed to revert message", error)
|
||||||
showAlertDialog("Failed to revert to message", {
|
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
|
||||||
title: "Revert failed",
|
title: t("sessionView.alerts.revertFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -178,8 +239,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to fork session", error)
|
log.error("Failed to fork session", error)
|
||||||
showAlertDialog("Failed to fork session", {
|
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
|
||||||
title: "Fork failed",
|
title: t("sessionView.alerts.forkFailed.title"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -191,7 +252,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
when={session()}
|
when={session()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<div class="text-center text-gray-500">Session not found</div>
|
<div class="text-center text-gray-500">{t("sessionView.fallback.sessionNotFound")}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -224,17 +285,52 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<PromptInput
|
<Show when={attachments().length > 0}>
|
||||||
instanceId={props.instanceId}
|
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||||
instanceFolder={props.instanceFolder}
|
<For each={attachments()}>
|
||||||
sessionId={activeSession.id}
|
{(attachment) => {
|
||||||
onSend={handleSendMessage}
|
const isText = attachment.source.type === "text"
|
||||||
onRunShell={handleRunShell}
|
return (
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||||
isSessionBusy={sessionBusy()}
|
<span class="font-mono">{attachment.display}</span>
|
||||||
onAbortSession={handleAbortSession}
|
<Show when={isText}>
|
||||||
registerQuoteHandler={registerQuoteHandler}
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
class="attachment-expand"
|
||||||
|
onClick={() => handleExpandTextAttachment(attachment)}
|
||||||
|
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
|
||||||
|
title={t("sessionView.attachments.insertPastedTextTitle")}
|
||||||
|
>
|
||||||
|
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="attachment-remove"
|
||||||
|
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||||
|
aria-label={t("sessionView.attachments.removeAriaLabel")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<PromptInput
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
instanceFolder={props.instanceFolder}
|
||||||
|
sessionId={activeSession.id}
|
||||||
|
onSend={handleSendMessage}
|
||||||
|
onRunShell={handleRunShell}
|
||||||
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
isSessionBusy={sessionBusy()}
|
||||||
|
disabled={sessionNeedsInput()}
|
||||||
|
onAbortSession={handleAbortSession}
|
||||||
|
registerQuoteHandler={registerQuoteHandler}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user