mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-13 01:41:35 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34e807108 | ||
|
|
c8d4a5099a | ||
|
|
56d1e00f2f | ||
|
|
34706228bb | ||
|
|
ca06bd99d7 | ||
|
|
bce975d940 | ||
|
|
275770b51b | ||
|
|
e8623d3132 | ||
|
|
31414521f6 |
71 changed files with 4342 additions and 2268 deletions
236
.github/workflows/build-and-upload.yml
vendored
236
.github/workflows/build-and-upload.yml
vendored
|
|
@ -317,15 +317,98 @@ jobs:
|
|||
- name: Verify bundled Node resource (Electron Linux)
|
||||
run: node scripts/verify-bundled-node.cjs packages/electron-app/electron/resources@linux-x64
|
||||
|
||||
- name: Verify Electron portable archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
archives=(packages/electron-app/release/*.tar.gz)
|
||||
if [ "${#archives[@]}" -ne 1 ]; then
|
||||
echo "Expected one Electron Linux tar.gz, found ${#archives[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extract_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$extract_dir"' EXIT
|
||||
tar -xzf "${archives[0]}" -C "$extract_dir"
|
||||
|
||||
binary=$(find "$extract_dir" -type f -name CodeNomad -print -quit)
|
||||
if [ -z "$binary" ] || [ ! -x "$binary" ]; then
|
||||
echo "Electron portable archive does not contain an executable CodeNomad binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resources_dir="$(dirname "$binary")/resources"
|
||||
node scripts/smoke-packaged-resources.cjs \
|
||||
--resources "$resources_dir" \
|
||||
--target linux-x64
|
||||
|
||||
app_asar="$resources_dir/app.asar"
|
||||
if [ ! -f "$app_asar" ]; then
|
||||
echo "Electron portable archive does not contain resources/app.asar" >&2
|
||||
exit 1
|
||||
fi
|
||||
node - "$app_asar" <<'NODE'
|
||||
const asar = require("@electron/asar")
|
||||
const path = require("node:path")
|
||||
const archive = process.argv[2]
|
||||
const files = asar.listPackage(archive).map((file) =>
|
||||
file.replace(/^[/\\]/, "").replaceAll("\\", "/"),
|
||||
)
|
||||
const fileSet = new Set(files)
|
||||
const loadingPath = "dist/renderer/loading.html"
|
||||
if (!fileSet.has(loadingPath)) {
|
||||
throw new Error("Electron app.asar is missing dist/renderer/loading.html")
|
||||
}
|
||||
if (!files.some((file) => file.startsWith("dist/renderer/assets/") && !file.endsWith("/"))) {
|
||||
throw new Error("Electron app.asar does not contain renderer assets")
|
||||
}
|
||||
const html = asar.extractFile(archive, loadingPath).toString("utf8")
|
||||
for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
|
||||
const reference = match[1]
|
||||
if (/^(?:[a-z]+:|#)/i.test(reference)) continue
|
||||
const cleanReference = reference.split(/[?#]/, 1)[0]
|
||||
const target = cleanReference.startsWith("/")
|
||||
? cleanReference.slice(1)
|
||||
: path.posix.normalize(path.posix.join(path.posix.dirname(loadingPath), cleanReference))
|
||||
if (!fileSet.has(target)) {
|
||||
throw new Error(`Electron app.asar is missing renderer reference ${target}`)
|
||||
}
|
||||
}
|
||||
NODE
|
||||
|
||||
ldd "$binary" | tee /tmp/codenomad-electron-ldd.txt
|
||||
if grep -q "not found" /tmp/codenomad-electron-ldd.txt; then
|
||||
echo "Electron portable binary has unresolved shared libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload release assets
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
files=(packages/electron-app/release/*.tar.gz)
|
||||
if [ "${#files[@]}" -ne 1 ]; then
|
||||
echo "Expected one Electron Linux release asset, found ${#files[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file="${files[0]}"
|
||||
asset_name=$(basename "$file")
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then
|
||||
echo "Uploaded Electron Linux asset is missing from release: $asset_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
CodeNomad-Electron-linux-*.zip|CodeNomad-Electron-linux-*.AppImage|CodeNomad-Electron-linux-*.flatpak)
|
||||
echo "Deleting obsolete Linux asset $asset"
|
||||
gh release delete-asset "$TAG" "$asset" --yes
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
- name: Upload Actions artifacts (Electron Linux)
|
||||
|
|
@ -333,9 +416,7 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||
path: |
|
||||
packages/electron-app/release/*.zip
|
||||
packages/electron-app/release/*.AppImage
|
||||
path: packages/electron-app/release/*.tar.gz
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: error
|
||||
|
||||
|
|
@ -364,6 +445,9 @@ jobs:
|
|||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Sync Tauri package version
|
||||
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
|
|
@ -451,6 +535,9 @@ jobs:
|
|||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Sync Tauri package version
|
||||
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
|
|
@ -539,6 +626,9 @@ jobs:
|
|||
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
shell: bash
|
||||
|
||||
- name: Sync Tauri package version
|
||||
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
|
|
@ -643,6 +733,9 @@ jobs:
|
|||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Sync Tauri package version
|
||||
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
|
|
@ -669,53 +762,135 @@ jobs:
|
|||
echo "Tauri CLI failed to load after retries" >&2
|
||||
exit 1
|
||||
|
||||
- name: Build Linux bundle (Tauri)
|
||||
- name: Build Debian package (Tauri)
|
||||
working-directory: packages/tauri-app
|
||||
run: npm exec -- tauri build
|
||||
run: npm exec -- tauri build --bundles deb
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
- name: Package and verify Tauri deb
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle/deb"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
VERSION_TO_USE="${VERSION:-}"
|
||||
if [ -z "$VERSION_TO_USE" ]; then
|
||||
VERSION_TO_USE=$(node -p "require('./packages/tauri-app/package.json').version")
|
||||
fi
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
shopt -s nullglob
|
||||
|
||||
find_one() {
|
||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||
}
|
||||
|
||||
appimage=$(find_one "*.AppImage")
|
||||
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||
|
||||
if [ -z "$appimage" ] || [ ! -f "$fallback_bin" ]; then
|
||||
echo "Missing bundle(s): appimage=${appimage:-none} binary=$fallback_bin" >&2
|
||||
debs=("$BUNDLE_ROOT"/*.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "Expected one Tauri deb under $BUNDLE_ROOT, found ${#debs[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.AppImage"
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION}.zip" "$fallback_bin"
|
||||
artifact="$ARTIFACT_DIR/CodeNomad-Tauri-linux-x64-${VERSION_TO_USE}.deb"
|
||||
cp "${debs[0]}" "$artifact"
|
||||
dpkg-deb --info "$artifact"
|
||||
dpkg-deb --contents "$artifact"
|
||||
package_version=$(dpkg-deb --field "$artifact" Version)
|
||||
if [ "$package_version" != "$VERSION_TO_USE" ]; then
|
||||
echo "Tauri deb version mismatch: expected $VERSION_TO_USE, found $package_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extract_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$extract_dir"' EXIT
|
||||
dpkg-deb --extract "$artifact" "$extract_dir"
|
||||
|
||||
binary="$extract_dir/usr/bin/codenomad-tauri"
|
||||
resources_dir="$extract_dir/usr/lib/CodeNomad/resources"
|
||||
desktop_file="$extract_dir/usr/share/applications/CodeNomad.desktop"
|
||||
for required in "$binary" "$resources_dir" "$desktop_file"; do
|
||||
if [ ! -e "$required" ]; then
|
||||
echo "Missing path in Tauri deb: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
grep -Fx "Exec=codenomad-tauri" "$desktop_file"
|
||||
if grep -Fxq "NoDisplay=true" "$desktop_file"; then
|
||||
echo "Generated Tauri desktop entry must be visible" >&2
|
||||
exit 1
|
||||
fi
|
||||
node scripts/smoke-packaged-resources.cjs \
|
||||
--resources "$resources_dir" \
|
||||
--loading "$resources_dir/ui-loading" \
|
||||
--target linux-x64
|
||||
|
||||
ldd "$binary" | tee /tmp/codenomad-tauri-ldd.txt
|
||||
if grep -q "not found" /tmp/codenomad-tauri-ldd.txt; then
|
||||
echo "Tauri deb binary has unresolved shared libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify Tauri deb installation on Ubuntu 24.04
|
||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
debs=(packages/tauri-app/release-tauri/*.deb)
|
||||
if [ "${#debs[@]}" -ne 1 ]; then
|
||||
echo "Expected one packaged Tauri deb, found ${#debs[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact=$(realpath "${debs[0]}")
|
||||
docker run --rm \
|
||||
--volume "$artifact:/tmp/codenomad.deb:ro" \
|
||||
ubuntu:24.04 \
|
||||
bash -euc '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y /tmp/codenomad.deb >/dev/null
|
||||
test -x /usr/bin/codenomad-tauri
|
||||
test -x /usr/lib/CodeNomad/resources/node/linux-x64/bin/node
|
||||
test -f /usr/lib/CodeNomad/resources/server/dist/bin.js
|
||||
test -f /usr/lib/CodeNomad/resources/ui-loading/loading.html
|
||||
/usr/lib/CodeNomad/resources/node/linux-x64/bin/node \
|
||||
/usr/lib/CodeNomad/resources/server/dist/bin.js --version
|
||||
ldd /usr/bin/codenomad-tauri | tee /tmp/codenomad-tauri-ldd.txt
|
||||
! grep -q "not found" /tmp/codenomad-tauri-ldd.txt
|
||||
'
|
||||
|
||||
- name: Upload Actions artifacts (Tauri Linux)
|
||||
if: ${{ inputs.upload_actions_artifacts }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||
path: packages/tauri-app/release-tauri/*
|
||||
path: packages/tauri-app/release-tauri/*.deb
|
||||
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
files=(packages/tauri-app/release-tauri/*.deb)
|
||||
if [ "${#files[@]}" -ne 1 ]; then
|
||||
echo "Expected one Tauri Linux release asset, found ${#files[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file="${files[0]}"
|
||||
asset_name=$(basename "$file")
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq -- "$asset_name"; then
|
||||
echo "Uploaded Tauri Linux asset is missing from release: $asset_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh release view "$TAG" --json assets --jq '.assets[].name' | while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
CodeNomad-Tauri-linux-*.zip|CodeNomad-Tauri-linux-*.AppImage|CodeNomad-Tauri-linux-*.rpm|CodeNomad-Tauri-linux-*.flatpak)
|
||||
echo "Deleting obsolete Linux asset $asset"
|
||||
gh release delete-asset "$TAG" "$asset" --yes
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-tauri-linux-arm64:
|
||||
|
|
@ -772,6 +947,9 @@ jobs:
|
|||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Sync Tauri package version
|
||||
run: npm run sync:version --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references.
|
|||
1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated`
|
||||
- Events are pushed through the instance event stream
|
||||
|
||||
2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler
|
||||
- **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true
|
||||
- **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
|
||||
- **Action:** Automatically calls `Permission.reply()`, skips modal display
|
||||
- **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()`
|
||||
2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled)
|
||||
- **File:** `packages/server/src/permissions/auto-accept-manager.ts`
|
||||
- **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup
|
||||
- **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes
|
||||
3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents`
|
||||
- **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately
|
||||
- **Branch:** ELSE (normal flow)
|
||||
- **Mechanism:** Permission queued in `permissionQueues` signal
|
||||
- **Action:** Display approval modal
|
||||
|
|
|
|||
|
|
@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync:
|
|||
|
||||
1. **Server emits** `permission.asked` or `permission.updated` SSE event
|
||||
- Pushed through instance event stream
|
||||
2. **UI Store receives** via `serverEvents`
|
||||
2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled)
|
||||
- File: `packages/server/src/permissions/auto-accept-manager.ts`
|
||||
- Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change
|
||||
- Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI
|
||||
3. **UI Store receives** via `serverEvents`
|
||||
- File: `packages/ui/src/stores/instances.ts`
|
||||
- **Branch:** IF `isPermissionAutoAcceptEnabled()`
|
||||
- Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
|
||||
- Action: Calls reply immediately, skips modal
|
||||
- **Branch:** ELSE
|
||||
- Mechanism: Queued in `permissionQueues`
|
||||
- Action: Display modal
|
||||
3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
|
||||
4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
|
||||
5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
|
||||
6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
|
||||
7. **Optimistic Update:** `removePermissionV2()` in bridge
|
||||
8. **SSE Confirmation:** `permission.replied` event
|
||||
- **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue
|
||||
- **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal
|
||||
4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
|
||||
5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
|
||||
6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
|
||||
7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
|
||||
8. **Optimistic Update:** `removePermissionV2()` in bridge
|
||||
9. **SSE Confirmation:** `permission.replied` event
|
||||
- **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
|
||||
|
||||
## Session Event Handling
|
||||
|
|
|
|||
48
BUILD.md
48
BUILD.md
|
|
@ -56,14 +56,14 @@ bun run build:win-arm64
|
|||
### Linux
|
||||
|
||||
```bash
|
||||
# x64 (64-bit)
|
||||
bun run build:linux
|
||||
# Portable Electron archive (x64)
|
||||
npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
# ARM64
|
||||
bun run build:linux-arm64
|
||||
# Tauri Debian package (x64)
|
||||
npm exec --workspace @codenomad/tauri-app -- tauri build --bundles deb
|
||||
```
|
||||
|
||||
**Output formats:** `.AppImage`, `.deb`, `.tar.gz`
|
||||
**Release formats:** Electron `.tar.gz` portable archive and Tauri `.deb` installer.
|
||||
|
||||
### Build All Platforms
|
||||
|
||||
|
|
@ -83,27 +83,29 @@ The build script performs these steps:
|
|||
|
||||
## Output
|
||||
|
||||
Binaries are generated in the `release/` directory:
|
||||
Build artifacts are generated in package-specific output directories:
|
||||
|
||||
```
|
||||
release/
|
||||
├── CodeNomad-0.1.0-mac-universal.dmg
|
||||
├── CodeNomad-0.1.0-mac-universal.zip
|
||||
├── CodeNomad-0.1.0-win-x64.exe
|
||||
├── CodeNomad-0.1.0-linux-x64.AppImage
|
||||
└── ...
|
||||
packages/electron-app/release/
|
||||
└── CodeNomad-Electron-linux-x64-0.18.0.tar.gz
|
||||
|
||||
packages/tauri-app/target/release/bundle/deb/
|
||||
└── CodeNomad_0.18.0_amd64.deb
|
||||
```
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
```
|
||||
CodeNomad-{version}-{os}-{arch}.{ext}
|
||||
CodeNomad-Electron-{os}-{arch}-{version}.{ext}
|
||||
CodeNomad-Tauri-{os}-{arch}-{version}.{ext}
|
||||
```
|
||||
|
||||
- **version**: From package.json (e.g., `0.1.0`)
|
||||
- **os**: `mac`, `win`, `linux`
|
||||
- **version**: From package.json (e.g., `0.18.0`)
|
||||
- **os**: `macos`, `windows`, `linux`
|
||||
- **arch**: `x64`, `arm64`, `universal`
|
||||
- **ext**: `dmg`, `zip`, `exe`, `AppImage`, `deb`, `tar.gz`
|
||||
- **ext**: `dmg`, `zip`, `exe`, `deb`, `tar.gz`
|
||||
|
||||
The Tauri build directory uses Tauri's native Debian filename. CI renames the package to the convention above when preparing release assets.
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
|
|
@ -121,9 +123,9 @@ CodeNomad-{version}-{os}-{arch}.{ext}
|
|||
|
||||
### Linux
|
||||
|
||||
- **Build on:** Any platform
|
||||
- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch
|
||||
- **Dependencies:** Varies by distro
|
||||
- **Build on:** Linux x64
|
||||
- **Electron portable:** extract the tar.gz and run the `CodeNomad` executable
|
||||
- **Tauri deb:** built and installation-tested on Ubuntu 24.04; older distributions are not yet guaranteed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
@ -136,13 +138,7 @@ xcode-select --install
|
|||
|
||||
### Build fails on Linux
|
||||
|
||||
```bash
|
||||
# Install dependencies (Debian/Ubuntu)
|
||||
sudo apt-get install -y rpm
|
||||
|
||||
# Install dependencies (Fedora)
|
||||
sudo dnf install -y rpm-build
|
||||
```
|
||||
Install the Electron and Tauri build dependencies documented by their upstream projects. Release builds currently target Linux x64 and produce an Electron portable archive plus a Tauri Debian package.
|
||||
|
||||
### "electron-builder not found"
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ Download the latest installer for your platform from [Releases](https://github.c
|
|||
|----------|---------|
|
||||
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
|
||||
| Windows | NSIS Installer, ZIP (x64, ARM64) |
|
||||
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
|
||||
| Linux | Tauri deb, Electron portable tar.gz (x64) |
|
||||
|
||||
The Tauri deb is currently built and installation-tested on Ubuntu 24.04. Compatibility with older Debian-based distributions is not yet guaranteed.
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
|
||||
|
|
@ -169,7 +171,7 @@ On Intel Macs, also check **System Settings → Privacy & Security** on first la
|
|||
WebKitGTK DMA-BUF/GBM issue. Run with:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
|
||||
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad-tauri
|
||||
```
|
||||
|
||||
See full workaround in the original README.
|
||||
|
|
|
|||
|
|
@ -584,7 +584,7 @@ npm run package # Create distributable
|
|||
|
||||
- macOS: DMG + auto-update
|
||||
- Windows: NSIS installer + auto-update
|
||||
- Linux: AppImage + deb/rpm
|
||||
- Linux: Electron portable tar.gz + Tauri deb
|
||||
|
||||
## Configuration Files
|
||||
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -13454,6 +13454,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/sdk": "^1.17.8",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"minServerVersion": "0.17.0",
|
||||
"minServerVersion": "0.18.0",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,12 +131,10 @@
|
|||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"executableName": "CodeNomad",
|
||||
"target": [
|
||||
{
|
||||
"target": "zip"
|
||||
},
|
||||
{
|
||||
"target": "AppImage"
|
||||
"target": "tar.gz"
|
||||
}
|
||||
],
|
||||
"artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/sdk": "^1.17.8",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export interface WorkspaceDescriptor {
|
|||
export interface WorkspaceCreateRequest {
|
||||
path: string
|
||||
name?: string
|
||||
forceNew?: boolean
|
||||
}
|
||||
|
||||
export interface WorkspaceCloneRequest {
|
||||
|
|
@ -50,7 +51,10 @@ export interface WorkspaceCloneResponse {
|
|||
path: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor
|
||||
export type WorkspaceCreateResponse = WorkspaceDescriptor & {
|
||||
/** True when an active workspace with the same canonical path was returned. */
|
||||
reused?: true
|
||||
}
|
||||
export type WorkspaceListResponse = WorkspaceDescriptor[]
|
||||
export type WorkspaceDetailResponse = WorkspaceDescriptor
|
||||
|
||||
|
|
@ -365,6 +369,10 @@ export interface VoiceModeStateResponse {
|
|||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface YoloStateResponse {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RemoteServerProfile {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -414,6 +422,8 @@ export type WorkspaceEventType =
|
|||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
| "yolo.stateChanged"
|
||||
| "yolo.autoAccepted"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
|
|
@ -428,6 +438,8 @@ export type WorkspaceEventPayload =
|
|||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
| { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean }
|
||||
| { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export class EventBus extends EventEmitter {
|
|||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
this.on("yolo.stateChanged", handler)
|
||||
this.on("yolo.autoAccepted", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
|
|
@ -44,6 +46,8 @@ export class EventBus extends EventEmitter {
|
|||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
this.off("yolo.stateChanged", handler)
|
||||
this.off("yolo.autoAccepted", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
668
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
668
packages/server/src/permissions/auto-accept-manager.test.ts
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { EventBus } from "../events/bus"
|
||||
import { AutoAcceptManager, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager"
|
||||
import type { InstanceStreamEvent } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const noopLogger: Logger = {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
trace() {},
|
||||
isLevelEnabled() {
|
||||
return false
|
||||
},
|
||||
child() {
|
||||
return noopLogger
|
||||
},
|
||||
} as unknown as Logger
|
||||
|
||||
function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<string, unknown>) {
|
||||
bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent })
|
||||
}
|
||||
|
||||
/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */
|
||||
function publishSession(
|
||||
bus: EventBus,
|
||||
instanceId: string,
|
||||
eventType: "session.updated" | "session.created" | "session.deleted",
|
||||
info: Record<string, unknown>,
|
||||
) {
|
||||
publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } })
|
||||
}
|
||||
|
||||
describe("AutoAcceptManager session tree", () => {
|
||||
it("ingests session.updated to build the parent chain", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.toggle("inst", "child")
|
||||
assert.equal(manager.isEnabled("inst", "child"), true)
|
||||
assert.equal(manager.isEnabled("inst", "master"), true)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("treats a session with revert as a fork root", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", {
|
||||
id: "fork",
|
||||
parentID: "master",
|
||||
revert: { messageID: "m", partID: "p" },
|
||||
})
|
||||
|
||||
manager.toggle("inst", "fork")
|
||||
assert.equal(manager.isEnabled("inst", "fork"), true)
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("session.deleted removes the tree entry but keeps the toggle", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
manager.toggle("inst", "master")
|
||||
publishSession(bus, "inst", "session.deleted", { id: "master" })
|
||||
|
||||
// toggle is independent of the tree (survives deletion)
|
||||
assert.equal(manager.isEnabled("inst", "master"), true)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager permission interception", () => {
|
||||
it("auto-replies to a v2 permission on an enabled family", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const accepted: Record<string, unknown>[] = []
|
||||
bus.on("yolo.autoAccepted", (e) => accepted.push(e))
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
|
||||
manager.toggle("inst", "child") // enable the whole family root
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
const call = replier.calls[0]
|
||||
assert.equal(call.instanceId, "inst")
|
||||
assert.equal(call.permissionId, "perm-1")
|
||||
assert.equal(call.sessionId, "child")
|
||||
assert.equal(call.source, "v2")
|
||||
assert.equal(call.reply, "once")
|
||||
assert.equal(accepted.length, 1)
|
||||
assert.equal((accepted[0] as any).permissionId, "perm-1")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("auto-replies to a legacy permission.asked event", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm-2", sessionID: "solo", type: "bash" },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].source, "legacy")
|
||||
assert.equal(replier.calls[0].permissionId, "perm-2")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("does not reply when the family is disabled", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-3", sessionID: "solo" },
|
||||
})
|
||||
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("ignores permission events without an id or sessionID", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } })
|
||||
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } })
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("deduplicates repeated emission of the same permission", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-dup", sessionID: "solo" },
|
||||
})
|
||||
}
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("clears in-flight tracking after the reply resolves so it can retry", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-retry", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-retry", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 2)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager state events", () => {
|
||||
it("publishes yolo.stateChanged with the new enabled value on toggle", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const changes: Record<string, unknown>[] = []
|
||||
bus.on("yolo.stateChanged", (e) => changes.push(e))
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
|
||||
manager.toggle("inst", "master")
|
||||
manager.toggle("inst", "master")
|
||||
|
||||
assert.equal(changes.length, 2)
|
||||
assert.equal((changes[0] as any).enabled, true)
|
||||
assert.equal((changes[1] as any).enabled, false)
|
||||
assert.equal((changes[0] as any).sessionId, "master")
|
||||
assert.equal((changes[0] as any).instanceId, "inst")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager lifecycle", () => {
|
||||
it("clearInstance drops tree and enabled state for the instance", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
manager.toggle("inst", "master")
|
||||
manager.clearInstance("inst")
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("clears state when the workspace stops", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
manager.toggle("inst", "master")
|
||||
bus.publish({ type: "workspace.stopped", workspaceId: "inst" })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "master"), false)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("stop() unsubscribes so no further events are processed", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
manager.stop()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "p", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager pending permissions drain", () => {
|
||||
it("drains a pending permission that arrived before enable", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
|
||||
// permission arrives while yolo is OFF
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-pending", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
// enabling yolo should drain the pending permission
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].permissionId, "perm-pending")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("drains pending permissions for the same family only", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null })
|
||||
publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-a", sessionID: "root-a" },
|
||||
})
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-b", sessionID: "root-b" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
manager.toggle("inst", "root-a")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].permissionId, "perm-a")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("does not re-drain already-auto-accepted permissions", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-1", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 1)
|
||||
|
||||
// toggling off then on should not re-drain the already-replied permission
|
||||
manager.toggle("inst", "solo") // off
|
||||
manager.toggle("inst", "solo") // on — drain runs but pending set is empty
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("re-drains pending when late session ancestry joins an enabled family", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
// master exists, yolo enabled on master
|
||||
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
|
||||
manager.toggle("inst", "master")
|
||||
|
||||
// child permission arrives BEFORE child's session ancestry is known.
|
||||
// At this point "child" is unknown to the tree, so it resolves as its
|
||||
// own root and the permission is NOT auto-accepted.
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-late", sessionID: "child" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
assert.equal(replier.calls.length, 0)
|
||||
|
||||
// child session ancestry arrives — child.parentID = "master".
|
||||
// ingestSession → upsertSession → migrateEnabledRoots makes "child"
|
||||
// resolve to "master" (enabled). drainPending should fire and accept
|
||||
// the previously-pending permission.
|
||||
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].permissionId, "perm-late")
|
||||
assert.equal(replier.calls[0].sessionId, "child")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager permission replied cleanup", () => {
|
||||
it("removes a pending permission on permission.v2.replied", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-x", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
// user manually replies → replied event cleans up pending
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.replied",
|
||||
properties: { id: "perm-x" },
|
||||
})
|
||||
|
||||
// enabling yolo now should NOT drain the already-replied permission
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("removes a pending permission on legacy permission.replied", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm-y", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.replied",
|
||||
properties: { requestID: "perm-y" },
|
||||
})
|
||||
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager clearInstance clears pending", () => {
|
||||
it("drops pending permissions on clearInstance", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-z", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
manager.clearInstance("inst")
|
||||
|
||||
// re-create session and enable — pending set should be empty
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager permission.updated source inference", () => {
|
||||
it("preserves the original v2 source when permission.updated arrives", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
// yolo is OFF — permission goes to pending with source "v2"
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-v2", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
// enable yolo, then send permission.updated — should keep source "v2"
|
||||
manager.toggle("inst", "solo")
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.updated",
|
||||
properties: { id: "perm-v2", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 1)
|
||||
assert.equal(replier.calls[0].source, "v2")
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("skips permission.updated for a permission not in pending", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
// permission.updated for a permission that was never asked (not in pending)
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.updated",
|
||||
properties: { id: "perm-unknown", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager replier failure handling", () => {
|
||||
it("keeps permission pending and does not emit autoAccepted on failure", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const autoAccepted: unknown[] = []
|
||||
bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e))
|
||||
const failingReplier: PermissionReplier = async () => {
|
||||
throw new Error("connection refused")
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-fail", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(autoAccepted.length, 0)
|
||||
// permission should still be pending (enabling again would try again)
|
||||
manager.toggle("inst", "solo") // off
|
||||
manager.toggle("inst", "solo") // on — drain retries
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(autoAccepted.length, 0)
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
|
||||
it("stops retrying after MAX_REPLY_ATTEMPTS", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
let callCount = 0
|
||||
const failingReplier: PermissionReplier = async () => {
|
||||
callCount++
|
||||
throw new Error("timeout")
|
||||
}
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-stuck", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
const attemptsAfterFirst = callCount
|
||||
|
||||
// trigger drains via session events
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
await flushMicrotasks()
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
await flushMicrotasks()
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
await flushMicrotasks()
|
||||
|
||||
// should have attempted at most MAX_REPLY_ATTEMPTS times total
|
||||
assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`)
|
||||
assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops
|
||||
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager workspace.error cleanup", () => {
|
||||
it("clears state when the workspace errors", () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
manager.toggle("inst", "solo")
|
||||
assert.equal(manager.isEnabled("inst", "solo"), true)
|
||||
|
||||
bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any })
|
||||
|
||||
assert.equal(manager.isEnabled("inst", "solo"), false)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptManager session.deleted clears pending", () => {
|
||||
it("removes pending permissions for a deleted session", async () => {
|
||||
const bus = new EventBus(noopLogger)
|
||||
const replier = makeRecordingReplier()
|
||||
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
|
||||
manager.start()
|
||||
|
||||
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
|
||||
|
||||
// permission arrives while yolo is OFF → goes to pending
|
||||
publishInstanceEvent(bus, "inst", {
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm-doomed", sessionID: "solo" },
|
||||
})
|
||||
await flushMicrotasks()
|
||||
|
||||
// session deleted → pending should be cleared
|
||||
publishSession(bus, "inst", "session.deleted", { id: "solo" })
|
||||
|
||||
// enabling yolo should not drain the deleted session's permission
|
||||
manager.toggle("inst", "solo")
|
||||
await flushMicrotasks()
|
||||
|
||||
assert.equal(replier.calls.length, 0)
|
||||
manager.stop()
|
||||
})
|
||||
})
|
||||
|
||||
function makeRecordingReplier() {
|
||||
const calls: AutoAcceptReply[] = []
|
||||
const replier: PermissionReplier = async (reply) => {
|
||||
calls.push(reply)
|
||||
}
|
||||
return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] }
|
||||
}
|
||||
|
||||
function flushMicrotasks() {
|
||||
return new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
296
packages/server/src/permissions/auto-accept-manager.ts
Normal file
296
packages/server/src/permissions/auto-accept-manager.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import type { EventBus } from "../events/bus"
|
||||
import type { Logger } from "../logger"
|
||||
import { AutoAcceptStore } from "./auto-accept-store"
|
||||
|
||||
/**
|
||||
* Server-side owner of Yolo (permission auto-accept).
|
||||
*
|
||||
* Subscribes to the instance SSE stream that the server already consumes
|
||||
* (`InstanceEventBridge` -> EventBus `instance.event`) and:
|
||||
* - maintains a per-instance session tree so family-root inheritance can
|
||||
* be resolved identically to the previous frontend implementation
|
||||
* - when a permission request arrives for an enabled family, auto-replies
|
||||
* via the injected {@link PermissionReplier} (same `"once"` semantics the
|
||||
* UI used to send)
|
||||
* - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus
|
||||
* so the UI stays a pure view
|
||||
*/
|
||||
|
||||
export type PermissionSource = "v2" | "legacy"
|
||||
export type PermissionReplyValue = "once"
|
||||
|
||||
export interface AutoAcceptReply {
|
||||
instanceId: string
|
||||
permissionId: string
|
||||
sessionId: string
|
||||
source: PermissionSource
|
||||
reply: PermissionReplyValue
|
||||
}
|
||||
|
||||
export type PermissionReplier = (reply: AutoAcceptReply) => Promise<void>
|
||||
|
||||
interface PendingPermission {
|
||||
permissionId: string
|
||||
sessionId: string
|
||||
source: PermissionSource
|
||||
}
|
||||
|
||||
interface AutoAcceptManagerDeps {
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
replier: PermissionReplier
|
||||
}
|
||||
|
||||
const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"])
|
||||
const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"])
|
||||
const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"])
|
||||
const SESSION_REMOVE_TYPES = new Set(["session.deleted"])
|
||||
|
||||
export class AutoAcceptManager {
|
||||
private static readonly MAX_REPLY_ATTEMPTS = 3
|
||||
private readonly store = new AutoAcceptStore()
|
||||
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
|
||||
private readonly inFlight = new Set<string>()
|
||||
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
|
||||
private readonly pending = new Map<string, Map<string, PendingPermission>>()
|
||||
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
|
||||
private readonly replyAttempts = new Map<string, number>()
|
||||
private unsubscribe?: () => void
|
||||
|
||||
constructor(private readonly deps: AutoAcceptManagerDeps) {}
|
||||
|
||||
start(): void {
|
||||
if (this.unsubscribe) return
|
||||
const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => {
|
||||
if (!payload || !payload.instanceId || !payload.event) return
|
||||
this.handleInstanceEvent(payload.instanceId, payload.event)
|
||||
}
|
||||
const onStopped = (event: { workspaceId?: string }) => {
|
||||
if (event?.workspaceId) this.clearInstance(event.workspaceId)
|
||||
}
|
||||
const onError = (event: { workspace?: { id?: string } }) => {
|
||||
if (event?.workspace?.id) this.clearInstance(event.workspace.id)
|
||||
}
|
||||
this.deps.eventBus.on("instance.event", handler)
|
||||
this.deps.eventBus.on("workspace.stopped", onStopped)
|
||||
this.deps.eventBus.on("workspace.error", onError)
|
||||
this.unsubscribe = () => {
|
||||
this.deps.eventBus.off("instance.event", handler)
|
||||
this.deps.eventBus.off("workspace.stopped", onStopped)
|
||||
this.deps.eventBus.off("workspace.error", onError)
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.unsubscribe?.()
|
||||
this.unsubscribe = undefined
|
||||
}
|
||||
|
||||
isEnabled(instanceId: string, sessionId: string): boolean {
|
||||
return this.store.isEnabled(instanceId, sessionId)
|
||||
}
|
||||
|
||||
toggle(instanceId: string, sessionId: string): boolean {
|
||||
const enabled = this.store.toggle(instanceId, sessionId)
|
||||
this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled })
|
||||
if (enabled) {
|
||||
this.drainPending(instanceId, sessionId)
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
clearInstance(instanceId: string): void {
|
||||
this.store.clearInstance(instanceId)
|
||||
this.pending.delete(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(this.inFlight.keys())) {
|
||||
if (key.startsWith(prefix)) this.inFlight.delete(key)
|
||||
}
|
||||
for (const key of Array.from(this.replyAttempts.keys())) {
|
||||
if (key.startsWith(prefix)) this.replyAttempts.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void {
|
||||
if (!event || typeof event.type !== "string") return
|
||||
|
||||
if (SESSION_UPSERT_TYPES.has(event.type)) {
|
||||
this.ingestSession(instanceId, event.properties)
|
||||
return
|
||||
}
|
||||
if (SESSION_REMOVE_TYPES.has(event.type)) {
|
||||
const info = (event.properties as { info?: SessionProperties } | undefined)?.info
|
||||
const id = readString(info?.id) ?? readString(event.properties?.id)
|
||||
if (id) {
|
||||
this.store.removeSession(instanceId, id)
|
||||
this.removePendingForSession(instanceId, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
|
||||
this.handlePermissionReplied(instanceId, event.properties)
|
||||
return
|
||||
}
|
||||
if (PERMISSION_ASK_TYPES.has(event.type)) {
|
||||
this.handlePermissionRequest(instanceId, event.type, event.properties)
|
||||
}
|
||||
}
|
||||
|
||||
private ingestSession(instanceId: string, properties: unknown): void {
|
||||
// OpenCode wraps session records under `properties.info` for
|
||||
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
|
||||
// flat fallback only for defensive compatibility.
|
||||
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
|
||||
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
|
||||
| SessionProperties
|
||||
| undefined
|
||||
if (!session || typeof session.id !== "string") return
|
||||
const parentId = session.parentID ?? session.parentId ?? null
|
||||
const revert = session.revert ?? undefined
|
||||
this.store.upsertSession(instanceId, { id: session.id, parentId, revert })
|
||||
// Session ancestry may have changed (parent discovered, revert toggled).
|
||||
// Re-drain pending permissions whose family root may have migrated into
|
||||
// an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
|
||||
// ForInstance trigger on session.updated (#497).
|
||||
this.drainPending(instanceId, session.id)
|
||||
}
|
||||
|
||||
private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void {
|
||||
const request = permission as PermissionProperties | undefined
|
||||
if (!request) return
|
||||
const permissionId = readString(request.id)
|
||||
const sessionId = readString(request.sessionID) ?? readString(request.sessionId)
|
||||
if (!permissionId || !sessionId) return
|
||||
|
||||
// Infer source from the event type, but prefer the already-tracked source
|
||||
// for permission.updated (which may belong to a v2 permission).
|
||||
const existing = this.pending.get(instanceId)?.get(permissionId)
|
||||
const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy")
|
||||
|
||||
// `permission.updated` represents a detail change for a permission that
|
||||
// is *already* pending. If it is no longer in our pending set it was
|
||||
// already replied to (by us or the user) — skip to avoid a duplicate reply.
|
||||
if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.addPending(instanceId, { permissionId, sessionId, source })
|
||||
|
||||
if (!this.store.isEnabled(instanceId, sessionId)) return
|
||||
this.tryAutoAccept(instanceId, permissionId, sessionId, source)
|
||||
}
|
||||
|
||||
private handlePermissionReplied(instanceId: string, properties: unknown): void {
|
||||
const request = (properties as PermissionRepliedProperties | undefined) ?? {}
|
||||
const permissionId =
|
||||
readString(request.id) ??
|
||||
readString(request.requestID) ??
|
||||
readString(request.permissionID) ??
|
||||
readString(request.requestId) ??
|
||||
readString(request.permissionId)
|
||||
if (permissionId) this.removePending(instanceId, permissionId)
|
||||
}
|
||||
|
||||
private tryAutoAccept(
|
||||
instanceId: string,
|
||||
permissionId: string,
|
||||
sessionId: string,
|
||||
source: PermissionSource,
|
||||
): void {
|
||||
const key = `${instanceId}:${permissionId}`
|
||||
if (this.inFlight.has(key)) return
|
||||
const attempts = this.replyAttempts.get(key) ?? 0
|
||||
if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return
|
||||
this.inFlight.add(key)
|
||||
this.replyAttempts.set(key, attempts + 1)
|
||||
|
||||
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" }
|
||||
|
||||
void this.deps.replier(reply)
|
||||
.then(() => {
|
||||
this.replyAttempts.delete(key)
|
||||
this.removePending(instanceId, permissionId)
|
||||
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed")
|
||||
if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) {
|
||||
this.removePending(instanceId, permissionId)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
/** Auto-accept all pending permissions belonging to the same family root. */
|
||||
private drainPending(instanceId: string, sessionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (!instancePending || instancePending.size === 0) return
|
||||
const root = this.store.familyRoot(instanceId, sessionId)
|
||||
for (const entry of Array.from(instancePending.values())) {
|
||||
if (this.store.familyRoot(instanceId, entry.sessionId) === root) {
|
||||
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addPending(instanceId: string, entry: PendingPermission): void {
|
||||
let instancePending = this.pending.get(instanceId)
|
||||
if (!instancePending) {
|
||||
instancePending = new Map()
|
||||
this.pending.set(instanceId, instancePending)
|
||||
}
|
||||
instancePending.set(entry.permissionId, entry)
|
||||
}
|
||||
|
||||
private removePending(instanceId: string, permissionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (instancePending?.delete(permissionId)) {
|
||||
this.replyAttempts.delete(`${instanceId}:${permissionId}`)
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingForSession(instanceId: string, sessionId: string): void {
|
||||
const instancePending = this.pending.get(instanceId)
|
||||
if (!instancePending) return
|
||||
for (const [permId, entry] of Array.from(instancePending)) {
|
||||
if (entry.sessionId === sessionId) {
|
||||
instancePending.delete(permId)
|
||||
this.replyAttempts.delete(`${instanceId}:${permId}`)
|
||||
}
|
||||
}
|
||||
if (instancePending.size === 0) this.pending.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceStreamPayload {
|
||||
type?: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SessionProperties {
|
||||
id?: string
|
||||
parentID?: string | null
|
||||
parentId?: string | null
|
||||
revert?: unknown
|
||||
}
|
||||
|
||||
interface PermissionProperties {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
interface PermissionRepliedProperties {
|
||||
id?: string
|
||||
requestID?: string
|
||||
permissionID?: string
|
||||
requestId?: string
|
||||
permissionId?: string
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||
}
|
||||
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
182
packages/server/src/permissions/auto-accept-store.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store"
|
||||
|
||||
describe("resolveFamilyRoot", () => {
|
||||
it("returns the session id itself when no info is known", () => {
|
||||
assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan")
|
||||
})
|
||||
|
||||
it("keeps a loaded child as root when its parent is missing", () => {
|
||||
const root = resolveFamilyRoot("child", (id) =>
|
||||
id === "child" ? { id: "child", parentId: "parent" } : undefined,
|
||||
)
|
||||
assert.equal(root, "child")
|
||||
})
|
||||
|
||||
it("resolves to the master session when the full parent chain is loaded", () => {
|
||||
const root = resolveFamilyRoot("grandchild", (id) => {
|
||||
if (id === "grandchild") return { id: "grandchild", parentId: "child" }
|
||||
if (id === "child") return { id: "child", parentId: "master" }
|
||||
if (id === "master") return { id: "master", parentId: null }
|
||||
return undefined
|
||||
})
|
||||
assert.equal(root, "master")
|
||||
})
|
||||
|
||||
it("keeps a fork session (with revert) as its own root", () => {
|
||||
const root = resolveFamilyRoot("fork", (id) => {
|
||||
if (id === "fork")
|
||||
return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } }
|
||||
if (id === "master") return { id: "master", parentId: null }
|
||||
return undefined
|
||||
})
|
||||
assert.equal(root, "fork")
|
||||
})
|
||||
|
||||
it("terminates on cyclic parent chains without looping forever", () => {
|
||||
const root = resolveFamilyRoot("a", (id) => {
|
||||
if (id === "a") return { id: "a", parentId: "b" }
|
||||
if (id === "b") return { id: "b", parentId: "a" }
|
||||
return undefined
|
||||
})
|
||||
// cycle: last known id before re-entering the cycle is returned
|
||||
assert.ok(root === "a" || root === "b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptStore inheritance", () => {
|
||||
it("is disabled by default for an unknown session", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
assert.equal(store.isEnabled("inst", "s1"), false)
|
||||
})
|
||||
|
||||
it("enabling a parent enables every descendant that resolves to it", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
store.upsertSession("inst", { id: "grandchild", parentId: "child" })
|
||||
|
||||
store.setEnabled("inst", "master", true)
|
||||
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
assert.equal(store.isEnabled("inst", "grandchild"), true)
|
||||
})
|
||||
|
||||
it("enabling a child also covers the parent family root and siblings", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child-a", parentId: "master" })
|
||||
store.upsertSession("inst", { id: "child-b", parentId: "master" })
|
||||
|
||||
store.setEnabled("inst", "child-a", true)
|
||||
|
||||
assert.equal(store.isEnabled("inst", "child-a"), true)
|
||||
assert.equal(store.isEnabled("inst", "child-b"), true)
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
})
|
||||
|
||||
it("a fork session is isolated: enabling it does not enable its parent", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", {
|
||||
id: "fork",
|
||||
parentId: "master",
|
||||
revert: { messageID: "msg", partID: "part" },
|
||||
})
|
||||
|
||||
store.setEnabled("inst", "fork", true)
|
||||
|
||||
assert.equal(store.isEnabled("inst", "fork"), true)
|
||||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
})
|
||||
|
||||
it("disabling the family root clears the setting for all descendants", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
|
||||
store.setEnabled("inst", "child", true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
|
||||
store.setEnabled("inst", "master", false)
|
||||
assert.equal(store.isEnabled("inst", "child"), false)
|
||||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
})
|
||||
|
||||
it("toggle flips the resolved family-root state and reports the new value", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
|
||||
assert.equal(store.toggle("inst", "child"), true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
assert.equal(store.toggle("inst", "master"), false)
|
||||
assert.equal(store.isEnabled("inst", "child"), false)
|
||||
})
|
||||
|
||||
it("keeps per-instance state independent", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst-a", { id: "root", parentId: null })
|
||||
store.upsertSession("inst-b", { id: "root", parentId: null })
|
||||
|
||||
store.setEnabled("inst-a", "root", true)
|
||||
assert.equal(store.isEnabled("inst-a", "root"), true)
|
||||
assert.equal(store.isEnabled("inst-b", "root"), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("AutoAcceptStore session tree maintenance", () => {
|
||||
it("migrates enabled root when a parent is discovered later", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "child", parentId: "parent" })
|
||||
// parent unknown -> child is its own root
|
||||
store.setEnabled("inst", "child", true)
|
||||
|
||||
// later the parent shows up — root should migrate from "child" to "parent"
|
||||
store.upsertSession("inst", { id: "parent", parentId: null })
|
||||
assert.equal(store.isEnabled("inst", "parent"), true)
|
||||
assert.equal(store.isEnabled("inst", "child"), true)
|
||||
})
|
||||
|
||||
it("removing a session does not clear an enabled family root", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.setEnabled("inst", "master", true)
|
||||
store.removeSession("inst", "master")
|
||||
// the toggle is independent of the session tree: it survives session deletion
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
})
|
||||
|
||||
it("clearInstance drops both tree and enabled state", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.setEnabled("inst", "master", true)
|
||||
store.clearInstance("inst")
|
||||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
assert.equal(store.isEnabled("inst", "master"), false)
|
||||
})
|
||||
|
||||
it("changing revert status re-roots a session as a fork", () => {
|
||||
const store = new AutoAcceptStore()
|
||||
store.upsertSession("inst", { id: "master", parentId: null })
|
||||
store.upsertSession("inst", { id: "child", parentId: "master" })
|
||||
store.setEnabled("inst", "child", true)
|
||||
// parent family enabled
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
|
||||
// child becomes a fork
|
||||
store.upsertSession("inst", {
|
||||
id: "child",
|
||||
parentId: "master",
|
||||
revert: { messageID: "m", partID: "p" },
|
||||
})
|
||||
// now child resolves to itself; the family setting was on "master" so still on for master
|
||||
assert.equal(store.isEnabled("inst", "master"), true)
|
||||
// child is its own root now, not enabled unless toggled
|
||||
assert.equal(store.isEnabled("inst", "child"), false)
|
||||
})
|
||||
})
|
||||
128
packages/server/src/permissions/auto-accept-store.ts
Normal file
128
packages/server/src/permissions/auto-accept-store.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* In-memory permission auto-accept (Yolo) state, owned by the server.
|
||||
*
|
||||
* This is a faithful port of the previous frontend implementation
|
||||
* (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance
|
||||
* semantics are preserved exactly:
|
||||
* - state is keyed by the resolved *family root* session id
|
||||
* - a session with a `revert` snapshot is treated as its own root (fork)
|
||||
* - enabling any session enables its whole family root and vice-versa
|
||||
*
|
||||
* No persistence: state is lost on server restart, matching the "no
|
||||
* persistence for now" milestone.
|
||||
*/
|
||||
|
||||
export interface AutoAcceptSessionInfo {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
/** Truthy value marks the session as a fork that roots at itself. */
|
||||
revert?: unknown
|
||||
}
|
||||
|
||||
type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined
|
||||
|
||||
/**
|
||||
* Resolve the family-root session id for `sessionId` by walking the parent
|
||||
* chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so
|
||||
* inheritance behaviour does not change.
|
||||
*/
|
||||
export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string {
|
||||
let currentId = sessionId
|
||||
let lastKnownId = sessionId
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (currentId && !seen.has(currentId)) {
|
||||
seen.add(currentId)
|
||||
const session = getSession(currentId)
|
||||
if (!session) return lastKnownId
|
||||
lastKnownId = session.id
|
||||
if (session.revert) return session.id
|
||||
if (!session.parentId) return session.id
|
||||
currentId = session.parentId
|
||||
}
|
||||
|
||||
return currentId || sessionId
|
||||
}
|
||||
|
||||
export class AutoAcceptStore {
|
||||
/** instanceId -> set of enabled family-root session ids */
|
||||
private readonly enabled = new Map<string, Set<string>>()
|
||||
/** instanceId -> (sessionId -> info) */
|
||||
private readonly sessions = new Map<string, Map<string, AutoAcceptSessionInfo>>()
|
||||
|
||||
isEnabled(instanceId: string, sessionId: string): boolean {
|
||||
const root = this.familyRoot(instanceId, sessionId)
|
||||
return this.enabled.get(instanceId)?.has(root) ?? false
|
||||
}
|
||||
|
||||
setEnabled(instanceId: string, sessionId: string, enabled: boolean): void {
|
||||
const root = this.familyRoot(instanceId, sessionId)
|
||||
let roots = this.enabled.get(instanceId)
|
||||
if (!roots) {
|
||||
if (!enabled) return
|
||||
roots = new Set()
|
||||
this.enabled.set(instanceId, roots)
|
||||
}
|
||||
if (enabled) {
|
||||
roots.add(root)
|
||||
} else {
|
||||
roots.delete(root)
|
||||
if (roots.size === 0) {
|
||||
this.enabled.delete(instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggle(instanceId: string, sessionId: string): boolean {
|
||||
const next = !this.isEnabled(instanceId, sessionId)
|
||||
this.setEnabled(instanceId, sessionId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void {
|
||||
let tree = this.sessions.get(instanceId)
|
||||
if (!tree) {
|
||||
tree = new Map()
|
||||
this.sessions.set(instanceId, tree)
|
||||
}
|
||||
tree.set(info.id, {
|
||||
id: info.id,
|
||||
parentId: info.parentId ?? null,
|
||||
revert: info.revert,
|
||||
})
|
||||
this.migrateEnabledRoots(instanceId)
|
||||
}
|
||||
|
||||
removeSession(instanceId: string, sessionId: string): void {
|
||||
this.sessions.get(instanceId)?.delete(sessionId)
|
||||
}
|
||||
|
||||
clearInstance(instanceId: string): void {
|
||||
this.sessions.delete(instanceId)
|
||||
this.enabled.delete(instanceId)
|
||||
}
|
||||
|
||||
/** Resolves the family-root session id for the given session. */
|
||||
familyRoot(instanceId: string, sessionId: string): string {
|
||||
const tree = this.sessions.get(instanceId)
|
||||
return resolveFamilyRoot(sessionId, (id) => tree?.get(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves every enabled family root for an instance after the session
|
||||
* tree changes (new session, updated parent/revert). If a root now resolves
|
||||
* to a different id, the enabled entry is migrated so toggles survive late
|
||||
* ancestry discovery.
|
||||
*/
|
||||
private migrateEnabledRoots(instanceId: string): void {
|
||||
const roots = this.enabled.get(instanceId)
|
||||
if (!roots || roots.size === 0) return
|
||||
for (const oldRoot of Array.from(roots)) {
|
||||
const newRoot = this.familyRoot(instanceId, oldRoot)
|
||||
if (newRoot !== oldRoot) {
|
||||
roots.delete(oldRoot)
|
||||
roots.add(newRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/server/src/permissions/opencode-replier.ts
Normal file
47
packages/server/src/permissions/opencode-replier.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import { createInstanceClient } from "../workspaces/instance-client"
|
||||
import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager"
|
||||
|
||||
interface OpencodeReplierDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Default {@link PermissionReplier} that calls the OpenCode instance via the
|
||||
* generated SDK client over loopback, using the same `"once"` reply the UI
|
||||
* previously sent.
|
||||
*
|
||||
* Uses `createInstanceClient` so routes and body shapes are always correct
|
||||
* for the installed SDK version — no hand-assembled URLs.
|
||||
*/
|
||||
export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier {
|
||||
return async (reply: AutoAcceptReply) => {
|
||||
const client = createInstanceClient(deps.workspaceManager, reply.instanceId)
|
||||
if (!client) {
|
||||
throw new Error(`Yolo: instance ${reply.instanceId} has no open port`)
|
||||
}
|
||||
|
||||
const opts = { throwOnError: true } as const
|
||||
|
||||
if (reply.source === "v2") {
|
||||
await client.v2.session.permission.reply(
|
||||
{
|
||||
sessionID: reply.sessionId,
|
||||
requestID: reply.permissionId,
|
||||
reply: reply.reply,
|
||||
},
|
||||
opts,
|
||||
)
|
||||
} else {
|
||||
await client.permission.reply(
|
||||
{
|
||||
requestID: reply.permissionId,
|
||||
reply: reply.reply,
|
||||
},
|
||||
opts,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import { registerEventRoutes } from "./routes/events"
|
|||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerYoloRoutes } from "./routes/yolo"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
|
|
@ -31,6 +32,8 @@ import { registerPreviewRoutes } from "./routes/previews"
|
|||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import { AutoAcceptManager } from "../permissions/auto-accept-manager"
|
||||
import { createOpencodePermissionReplier } from "../permissions/opencode-replier"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
|
|
@ -192,6 +195,16 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
|
||||
const yoloManager = new AutoAcceptManager({
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "yolo" }),
|
||||
replier: createOpencodePermissionReplier({
|
||||
workspaceManager: deps.workspaceManager,
|
||||
logger: deps.logger.child({ component: "yolo" }),
|
||||
}),
|
||||
})
|
||||
yoloManager.start()
|
||||
|
||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||
|
||||
app.addHook("preHandler", (request, reply, done) => {
|
||||
|
|
@ -309,6 +322,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
voiceModeManager: deps.voiceModeManager,
|
||||
})
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerYoloRoutes(app, { yoloManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
|
|
@ -371,6 +385,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
stop: () => {
|
||||
yoloManager.stop()
|
||||
closeSseClients()
|
||||
return app.close()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface RouteDeps {
|
|||
const WorkspaceCreateSchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string().optional(),
|
||||
forceNew: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const WorkspaceCloneSchema = z.object({
|
||||
|
|
@ -68,9 +69,9 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||
app.post("/api/workspaces", async (request, reply) => {
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
const result = await deps.workspaceManager.create(body.path, body.name, { forceNew: body.forceNew })
|
||||
reply.code(201)
|
||||
return workspace
|
||||
return result.created ? result.workspace : { ...result.workspace, reused: true as const }
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
|
|
|
|||
26
packages/server/src/server/routes/yolo.ts
Normal file
26
packages/server/src/server/routes/yolo.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { FastifyInstance } from "fastify"
|
||||
import type { AutoAcceptManager } from "../../permissions/auto-accept-manager"
|
||||
|
||||
interface RouteDeps {
|
||||
yoloManager: AutoAcceptManager
|
||||
}
|
||||
|
||||
export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string; sessionId: string } }>(
|
||||
"/workspaces/:id/yolo/sessions/:sessionId",
|
||||
async (request) => {
|
||||
const { id, sessionId } = request.params
|
||||
return { enabled: deps.yoloManager.isEnabled(id, sessionId) }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string; sessionId: string } }>(
|
||||
"/workspaces/:id/yolo/sessions/:sessionId/toggle",
|
||||
async (request, reply) => {
|
||||
const { id, sessionId } = request.params
|
||||
const enabled = deps.yoloManager.toggle(id, sessionId)
|
||||
reply.code(200)
|
||||
return { enabled }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, describe, it } from "node:test"
|
||||
import pino from "pino"
|
||||
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceManager } from "../manager"
|
||||
import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })))
|
||||
})
|
||||
|
||||
async function createLinkedWorkspace(): Promise<{ root: string; target: string; link: string }> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-identity-"))
|
||||
temporaryDirectories.push(root)
|
||||
const target = path.join(root, "target")
|
||||
const link = path.join(root, "link")
|
||||
await mkdir(target)
|
||||
await symlink(target, link, process.platform === "win32" ? "junction" : "dir")
|
||||
return { root, target, link }
|
||||
}
|
||||
|
||||
function createManager(rootDir: string): WorkspaceManager {
|
||||
const logger = pino({ level: "silent" })
|
||||
const manager = new WorkspaceManager({
|
||||
rootDir,
|
||||
settings: { getOwner: () => ({ environmentVariables: {} }) },
|
||||
binaryResolver: {
|
||||
resolveDefault: () => ({ path: process.execPath, label: "Node.js", version: process.version }),
|
||||
},
|
||||
eventBus: new EventBus(logger),
|
||||
logger,
|
||||
getServerBaseUrl: () => "http://127.0.0.1:3000",
|
||||
} as unknown as ConstructorParameters<typeof WorkspaceManager>[0])
|
||||
|
||||
const internal = manager as any
|
||||
internal.runtime.launch = async () => ({
|
||||
pid: 123,
|
||||
port: 4321,
|
||||
exitPromise: new Promise(() => {}),
|
||||
getLastOutput: () => "",
|
||||
})
|
||||
internal.waitForWorkspaceReadiness = async () => undefined
|
||||
return manager
|
||||
}
|
||||
|
||||
describe("workspace identity", () => {
|
||||
it("normalizes Windows drive and UNC paths without affecting POSIX case", () => {
|
||||
assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\")
|
||||
assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`)
|
||||
assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/")
|
||||
})
|
||||
|
||||
it("resolves a symlink and its target to the same canonical launch path", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const targetResult = await resolveWorkspaceIdentity(target, root)
|
||||
const linkResult = await resolveWorkspaceIdentity(link, root)
|
||||
|
||||
assert.equal(linkResult.identityKey, targetResult.identityKey)
|
||||
assert.equal(linkResult.workspacePath, targetResult.workspacePath)
|
||||
assert.notEqual(linkResult.workspacePath, path.normalize(link))
|
||||
})
|
||||
|
||||
it("falls back to a normalized absolute identity when realpath fails", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-missing-"))
|
||||
temporaryDirectories.push(root)
|
||||
const result = await resolveWorkspaceIdentity("missing", root)
|
||||
const expected = path.resolve(root, "missing")
|
||||
|
||||
assert.equal(result.workspacePath, expected)
|
||||
assert.equal(result.identityKey, normalizeWorkspaceIdentityPath(expected))
|
||||
})
|
||||
|
||||
it("atomically reuses an active workspace reached through a symlink", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
const [targetResult, linkResult] = await Promise.all([manager.create(target), manager.create(link)])
|
||||
|
||||
assert.equal(Number(targetResult.created) + Number(linkResult.created), 1)
|
||||
assert.equal(targetResult.workspace.id, linkResult.workspace.id)
|
||||
assert.equal(manager.list().length, 1)
|
||||
})
|
||||
|
||||
it("shares an in-flight startup between canonical aliases", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
let launches = 0
|
||||
;(manager as any).runtime.launch = async () => {
|
||||
launches += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return {
|
||||
pid: 123,
|
||||
port: 4321,
|
||||
exitPromise: new Promise(() => {}),
|
||||
getLastOutput: () => "",
|
||||
}
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([manager.create(target), manager.create(link)])
|
||||
|
||||
assert.equal(launches, 1)
|
||||
assert.equal(Number(first.created) + Number(second.created), 1)
|
||||
assert.equal(first.workspace.id, second.workspace.id)
|
||||
assert.equal(first.workspace.status, "ready")
|
||||
assert.equal(second.workspace.status, "ready")
|
||||
})
|
||||
|
||||
it("releases a failed identity reservation so creation can be retried", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
let launches = 0
|
||||
;(manager as any).runtime.launch = async () => {
|
||||
launches += 1
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
throw new Error("launch failed")
|
||||
}
|
||||
|
||||
const failed = await Promise.allSettled([manager.create(target), manager.create(link)])
|
||||
assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"])
|
||||
assert.equal(launches, 1)
|
||||
|
||||
;(manager as any).runtime.launch = async () => ({
|
||||
pid: 456,
|
||||
port: 5432,
|
||||
exitPromise: new Promise(() => {}),
|
||||
getLastOutput: () => "",
|
||||
})
|
||||
const retry = await manager.create(target)
|
||||
|
||||
assert.equal(retry.created, true)
|
||||
assert.equal(retry.workspace.status, "ready")
|
||||
})
|
||||
|
||||
it("allows an explicit second workspace for the same canonical path", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
const [first, second] = await Promise.all([
|
||||
manager.create(target, undefined, { forceNew: true }),
|
||||
manager.create(link, undefined, { forceNew: true }),
|
||||
])
|
||||
|
||||
assert.equal(first.created, true)
|
||||
assert.equal(second.created, true)
|
||||
assert.notEqual(first.workspace.id, second.workspace.id)
|
||||
assert.equal(manager.list().length, 2)
|
||||
})
|
||||
|
||||
it("keeps the canonical reservation when a forced duplicate is deleted", async () => {
|
||||
const { root, target, link } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
const normalLaunch = deferred<{
|
||||
pid: number
|
||||
port: number
|
||||
exitPromise: Promise<never>
|
||||
getLastOutput: () => string
|
||||
}>()
|
||||
const forcedLaunch = deferred<{
|
||||
pid: number
|
||||
port: number
|
||||
exitPromise: Promise<never>
|
||||
getLastOutput: () => string
|
||||
}>()
|
||||
let launches = 0
|
||||
;(manager as any).runtime.launch = () => {
|
||||
launches += 1
|
||||
return launches === 1 ? normalLaunch.promise : forcedLaunch.promise
|
||||
}
|
||||
|
||||
const first = manager.create(target)
|
||||
while (manager.list().length < 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
const forced = manager.create(link, undefined, { forceNew: true })
|
||||
const forcedRejected = assert.rejects(forced, /Workspace creation cancelled/)
|
||||
while (manager.list().length < 2) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
const forcedWorkspace = manager.list().find((workspace) => workspace.id !== manager.list()[0].id)!
|
||||
await manager.delete(forcedWorkspace.id)
|
||||
|
||||
const reused = manager.create(link)
|
||||
normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" })
|
||||
forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => {}), getLastOutput: () => "" })
|
||||
const [firstResult, reusedResult] = await Promise.all([first, reused])
|
||||
|
||||
await forcedRejected
|
||||
assert.equal(launches, 2)
|
||||
assert.equal(firstResult.workspace.id, reusedResult.workspace.id)
|
||||
assert.equal(reusedResult.created, false)
|
||||
})
|
||||
|
||||
it("cancels an in-flight startup when its workspace is deleted", async () => {
|
||||
const { root, target } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
const launch = deferred<{
|
||||
pid: number
|
||||
port: number
|
||||
exitPromise: Promise<never>
|
||||
getLastOutput: () => string
|
||||
}>()
|
||||
;(manager as any).runtime.launch = () => launch.promise
|
||||
const events: any[] = []
|
||||
;(manager as any).options.eventBus.onEvent((event: any) => events.push(event))
|
||||
|
||||
const creation = manager.create(target)
|
||||
const creationRejected = assert.rejects(creation, /Workspace creation cancelled/)
|
||||
while (manager.list().length === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
const workspaceId = manager.list()[0].id
|
||||
await manager.delete(workspaceId)
|
||||
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" })
|
||||
|
||||
await creationRejected
|
||||
assert.equal(manager.get(workspaceId), undefined)
|
||||
assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1)
|
||||
})
|
||||
|
||||
it("waits for and cancels forced creations during shutdown", async () => {
|
||||
const { root, target } = await createLinkedWorkspace()
|
||||
const manager = createManager(root)
|
||||
const launch = deferred<{
|
||||
pid: number
|
||||
port: number
|
||||
exitPromise: Promise<never>
|
||||
getLastOutput: () => string
|
||||
}>()
|
||||
;(manager as any).runtime.launch = () => launch.promise
|
||||
|
||||
const creation = manager.create(target, undefined, { forceNew: true })
|
||||
const creationRejected = assert.rejects(creation, /Workspace creation cancelled/)
|
||||
while (manager.list().length === 0) {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
const shutdown = manager.shutdown()
|
||||
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), getLastOutput: () => "" })
|
||||
|
||||
await Promise.all([creationRejected, shutdown])
|
||||
assert.equal(manager.list().length, 0)
|
||||
})
|
||||
})
|
||||
49
packages/server/src/workspaces/instance-client.ts
Normal file
49
packages/server/src/workspaces/instance-client.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { WorkspaceManager } from "./manager"
|
||||
|
||||
const INSTANCE_HOST = "127.0.0.1"
|
||||
const LOOPBACK_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* Creates an OpenCode SDK client for direct loopback communication with a
|
||||
* running workspace instance.
|
||||
*
|
||||
* Routes and body shapes come from the auto-generated SDK contract
|
||||
* (`@opencode-ai/sdk`), eliminating handwritten URL construction that can
|
||||
* drift between SDK versions. Other server modules that need to call the
|
||||
* OpenCode instance directly should use this factory rather than building
|
||||
* `http://127.0.0.1:{port}/...` URLs by hand.
|
||||
*
|
||||
* All requests carry a 10-second timeout — loopback calls should be
|
||||
* near-instant; a hang indicates a stuck instance.
|
||||
*
|
||||
* The client is cheap to create (object only, no connection); create one per
|
||||
* call or cache per instance as needed. Returns `null` when the instance has
|
||||
* no open port yet.
|
||||
*/
|
||||
export function createInstanceClient(
|
||||
workspaceManager: WorkspaceManager,
|
||||
instanceId: string,
|
||||
): OpencodeClient | null {
|
||||
const port = workspaceManager.getInstancePort(instanceId)
|
||||
if (!port) return null
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId)
|
||||
if (authorization) {
|
||||
headers.authorization = authorization
|
||||
}
|
||||
|
||||
const workspace = workspaceManager.get(instanceId)
|
||||
|
||||
return createOpencodeClient({
|
||||
baseUrl: `http://${INSTANCE_HOST}:${port}/`,
|
||||
headers,
|
||||
fetch: (url, init) =>
|
||||
fetch(url, {
|
||||
...(init as RequestInit),
|
||||
signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
|
||||
}),
|
||||
...(workspace?.path ? { directory: workspace.path } : {}),
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { SettingsService } from "../settings/service"
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
OPENCODE_SERVER_USERNAME_ENV,
|
||||
resolveOpencodeServerAuth,
|
||||
} from "./opencode-auth"
|
||||
import { resolveWorkspaceIdentity } from "./workspace-identity"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
|
|
@ -38,8 +40,19 @@ interface WorkspaceManagerOptions {
|
|||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
|
||||
export interface WorkspaceCreateResult {
|
||||
workspace: WorkspaceDescriptor
|
||||
created: boolean
|
||||
}
|
||||
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly workspaceIdentities = new Map<string, string>()
|
||||
private readonly pendingWorkspaceCreations = new Map<string, Promise<WorkspaceCreateResult>>()
|
||||
private readonly pendingWorkspaceOwners = new Map<string, string>()
|
||||
private readonly activeWorkspaceCreations = new Set<Promise<WorkspaceCreateResult>>()
|
||||
private readonly cancelledWorkspaceCreations = new Set<string>()
|
||||
private shuttingDown = false
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly codeNomadPluginUrl: string
|
||||
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
|
||||
|
|
@ -65,6 +78,12 @@ export class WorkspaceManager {
|
|||
return this.opencodeAuth.get(id)?.authorization
|
||||
}
|
||||
|
||||
private findReadyWorkspaceByIdentity(identityKey: string): WorkspaceDescriptor | undefined {
|
||||
return Array.from(this.workspaces.values()).find((workspace) => {
|
||||
return workspace.status === "ready" && this.workspaceIdentities.get(workspace.id) === identityKey
|
||||
})
|
||||
}
|
||||
|
||||
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
|
|
@ -114,12 +133,65 @@ export class WorkspaceManager {
|
|||
browser.writeFile(relativePath, contents)
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
async create(folder: string, name?: string, options?: { forceNew?: boolean }): Promise<WorkspaceCreateResult> {
|
||||
const { workspacePath, identityKey } = await resolveWorkspaceIdentity(folder, this.options.rootDir)
|
||||
if (this.shuttingDown) {
|
||||
throw new Error("Workspace manager is shutting down")
|
||||
}
|
||||
if (options?.forceNew) {
|
||||
return this.trackWorkspaceCreation(this.createResolvedWorkspace(workspacePath, identityKey, name))
|
||||
}
|
||||
|
||||
const existing = this.findReadyWorkspaceByIdentity(identityKey)
|
||||
if (existing) {
|
||||
this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace")
|
||||
return { workspace: existing, created: false }
|
||||
}
|
||||
|
||||
const pending = this.pendingWorkspaceCreations.get(identityKey)
|
||||
if (pending) {
|
||||
const result = await pending
|
||||
return { workspace: result.workspace, created: false }
|
||||
}
|
||||
|
||||
const creation = this.trackWorkspaceCreation(
|
||||
this.createResolvedWorkspace(workspacePath, identityKey, name, (workspaceId) => {
|
||||
this.pendingWorkspaceOwners.set(identityKey, workspaceId)
|
||||
}),
|
||||
)
|
||||
this.pendingWorkspaceCreations.set(identityKey, creation)
|
||||
try {
|
||||
return await creation
|
||||
} finally {
|
||||
if (this.pendingWorkspaceCreations.get(identityKey) === creation) {
|
||||
this.pendingWorkspaceCreations.delete(identityKey)
|
||||
this.pendingWorkspaceOwners.delete(identityKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackWorkspaceCreation(creation: Promise<WorkspaceCreateResult>): Promise<WorkspaceCreateResult> {
|
||||
let tracked!: Promise<WorkspaceCreateResult>
|
||||
tracked = (async () => {
|
||||
try {
|
||||
return await creation
|
||||
} finally {
|
||||
this.activeWorkspaceCreations.delete(tracked)
|
||||
}
|
||||
})()
|
||||
this.activeWorkspaceCreations.add(tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
private async createResolvedWorkspace(
|
||||
workspacePath: string,
|
||||
identityKey: string,
|
||||
name?: string,
|
||||
onReserved?: (workspaceId: string) => void,
|
||||
): Promise<WorkspaceCreateResult> {
|
||||
const id = randomUUID()
|
||||
const binary = this.options.binaryResolver.resolveDefault()
|
||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||
clearWorkspaceSearchCache(workspacePath)
|
||||
|
||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||
|
|
@ -141,45 +213,47 @@ export class WorkspaceManager {
|
|||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
this.workspaceIdentities.set(id, identityKey)
|
||||
onReserved?.(id)
|
||||
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||
const envVars = (serverConfig as any)?.environmentVariables
|
||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||
const opencodeConfigContent = buildOpencodeConfigContent(
|
||||
resolveExistingOpencodeConfigContent(userEnvironment),
|
||||
this.codeNomadPluginUrl,
|
||||
)
|
||||
const serverBaseUrl = this.options.getServerBaseUrl()
|
||||
const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "")
|
||||
|
||||
const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({
|
||||
userEnvironment,
|
||||
processEnv: process.env,
|
||||
})
|
||||
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 = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_CONTENT: opencodeConfigContent,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: serverBaseUrl,
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
[OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`,
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
|
||||
try {
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||
const envVars = (serverConfig as any)?.environmentVariables
|
||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||
const opencodeConfigContent = buildOpencodeConfigContent(
|
||||
resolveExistingOpencodeConfigContent(userEnvironment),
|
||||
this.codeNomadPluginUrl,
|
||||
)
|
||||
const serverBaseUrl = this.options.getServerBaseUrl()
|
||||
const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "")
|
||||
|
||||
const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({
|
||||
userEnvironment,
|
||||
processEnv: process.env,
|
||||
})
|
||||
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 = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_CONTENT: opencodeConfigContent,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: serverBaseUrl,
|
||||
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
|
||||
[OPENCODE_SERVER_BASE_URL_ENV]: `${normalizedServerBaseUrl}${proxyPath}`,
|
||||
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
|
||||
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
||||
}
|
||||
|
||||
const logLevel = (serverConfig as any)?.logLevel
|
||||
this.throwIfWorkspaceCreationCancelled(id)
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
|
|
@ -188,25 +262,43 @@ export class WorkspaceManager {
|
|||
logLevel,
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
this.throwIfWorkspaceCreationCancelled(id)
|
||||
|
||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
if (runtimeVersion) {
|
||||
descriptor.binaryVersion = runtimeVersion
|
||||
}
|
||||
this.throwIfWorkspaceCreationCancelled(id)
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
|
||||
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
|
||||
return descriptor
|
||||
return { workspace: descriptor, created: true }
|
||||
} catch (error) {
|
||||
let stopFailure: unknown
|
||||
await this.runtime.stop(id).catch((stopError) => {
|
||||
stopFailure = stopError
|
||||
this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error")
|
||||
})
|
||||
const cancelled = this.cancelledWorkspaceCreations.delete(id) || !this.workspaces.has(id)
|
||||
if (cancelled && !stopFailure) {
|
||||
throw new Error("Workspace creation cancelled")
|
||||
}
|
||||
descriptor.status = "error"
|
||||
descriptor.error = error instanceof Error ? error.message : String(error)
|
||||
descriptor.error = stopFailure instanceof Error
|
||||
? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}`
|
||||
: error instanceof Error ? error.message : String(error)
|
||||
descriptor.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||
if (this.workspaces.has(id)) {
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
|
||||
}
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
|
||||
if (cancelled) {
|
||||
throw new Error(descriptor.error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -216,29 +308,54 @@ export class WorkspaceManager {
|
|||
if (!workspace) return undefined
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
|
||||
const wasStarting = workspace.status === "starting"
|
||||
const wasRunning = Boolean(workspace.pid)
|
||||
if (wasRunning) {
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
if (wasStarting) {
|
||||
this.cancelledWorkspaceCreations.add(id)
|
||||
}
|
||||
if (wasStarting || wasRunning) {
|
||||
try {
|
||||
await this.runtime.stop(id)
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
|
||||
})
|
||||
workspace.status = "error"
|
||||
workspace.error = error instanceof Error ? error.message : String(error)
|
||||
workspace.updatedAt = new Date().toISOString()
|
||||
this.options.eventBus.publish({ type: "workspace.error", workspace })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const identityKey = this.workspaceIdentities.get(id)
|
||||
if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) {
|
||||
this.pendingWorkspaceCreations.delete(identityKey)
|
||||
this.pendingWorkspaceOwners.delete(identityKey)
|
||||
}
|
||||
const stoppedEventPublished = workspace.status === "stopped"
|
||||
this.workspaces.delete(id)
|
||||
this.workspaceIdentities.delete(id)
|
||||
this.opencodeAuth.delete(id)
|
||||
if (!wasStarting) {
|
||||
this.cancelledWorkspaceCreations.delete(id)
|
||||
}
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
if (!stoppedEventPublished) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.shuttingDown = true
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
|
||||
const stopTasks: Array<Promise<void>> = []
|
||||
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (!workspace.pid) {
|
||||
if (workspace.status === "starting") {
|
||||
this.cancelledWorkspaceCreations.add(id)
|
||||
}
|
||||
if (!workspace.pid && workspace.status !== "starting") {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
continue
|
||||
}
|
||||
|
|
@ -247,15 +364,28 @@ export class WorkspaceManager {
|
|||
stopTasks.push(
|
||||
this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stopTasks.length > 0) {
|
||||
await Promise.allSettled(stopTasks)
|
||||
const stopResults = await Promise.allSettled(stopTasks)
|
||||
const failedStop = stopResults.find((result): result is PromiseRejectedResult => result.status === "rejected")
|
||||
if (failedStop) {
|
||||
throw failedStop.reason
|
||||
}
|
||||
}
|
||||
if (this.activeWorkspaceCreations.size > 0) {
|
||||
await Promise.allSettled(this.activeWorkspaceCreations)
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.workspaceIdentities.clear()
|
||||
this.pendingWorkspaceCreations.clear()
|
||||
this.pendingWorkspaceOwners.clear()
|
||||
this.activeWorkspaceCreations.clear()
|
||||
this.cancelledWorkspaceCreations.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
}
|
||||
|
|
@ -268,6 +398,12 @@ export class WorkspaceManager {
|
|||
return workspace
|
||||
}
|
||||
|
||||
private throwIfWorkspaceCreationCancelled(id: string): void {
|
||||
if (this.shuttingDown || this.cancelledWorkspaceCreations.has(id) || !this.workspaces.has(id)) {
|
||||
throw new Error("Workspace creation cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBinaryPath(identifier: string): string {
|
||||
if (!identifier) {
|
||||
return identifier
|
||||
|
|
|
|||
32
packages/server/src/workspaces/workspace-identity.ts
Normal file
32
packages/server/src/workspaces/workspace-identity.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { realpath, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string {
|
||||
const pathApi = platform === "win32" ? path.win32 : path.posix
|
||||
const normalized = pathApi.normalize(value)
|
||||
return platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceIdentity(
|
||||
folder: string,
|
||||
rootDir: string,
|
||||
): Promise<{ workspacePath: string; identityKey: string }> {
|
||||
const submittedPath = path.isAbsolute(folder) ? path.normalize(folder) : path.resolve(rootDir, folder)
|
||||
|
||||
try {
|
||||
const workspacePath = path.normalize(await realpath(submittedPath))
|
||||
const metadata = await stat(workspacePath, { bigint: true })
|
||||
return {
|
||||
workspacePath,
|
||||
identityKey: metadata.ino > 0n
|
||||
? `fs:${metadata.dev.toString()}:${metadata.ino.toString()}`
|
||||
: normalizeWorkspaceIdentityPath(workspacePath),
|
||||
}
|
||||
} catch {
|
||||
// Preserve the existing launch behavior when the path cannot be resolved yet.
|
||||
return {
|
||||
workspacePath: submittedPath,
|
||||
identityKey: normalizeWorkspaceIdentityPath(submittedPath),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
} from "./stores/ui"
|
||||
import { useConfig } from "./stores/preferences"
|
||||
import { recentFolders, useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
getExistingInstanceForFolder,
|
||||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from "./stores/instances"
|
||||
import {
|
||||
getSessions,
|
||||
getSessionRoot,
|
||||
activeSessionId,
|
||||
setActiveParentSession,
|
||||
clearActiveParentSession,
|
||||
|
|
@ -70,7 +71,6 @@ import {
|
|||
selectInstanceTab,
|
||||
selectSidecarTab,
|
||||
} from "./stores/app-tabs"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
|
|
@ -280,14 +280,15 @@ const App: Component = () => {
|
|||
if (!folderPath) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||
const projectName = getProjectNameForFolder(folderPath)
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
|
||||
if (!options?.forceNew) {
|
||||
const existingInstance = getExistingInstanceForFolder(folderPath)
|
||||
if (existingInstance) {
|
||||
recordWorkspaceLaunch(existingInstance.folder, selectedBinary, folderPath)
|
||||
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id })
|
||||
return
|
||||
}
|
||||
|
|
@ -295,13 +296,20 @@ const App: Component = () => {
|
|||
|
||||
setIsSelectingFolder(true)
|
||||
try {
|
||||
const instanceId = await createInstance(folderPath, selectedBinary, projectName)
|
||||
selectInstanceTab(instanceId)
|
||||
const result = await createInstance(folderPath, selectedBinary, projectName, { forceNew: options?.forceNew })
|
||||
if (result.reused) {
|
||||
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
|
||||
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: result.instanceId })
|
||||
return
|
||||
}
|
||||
|
||||
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
|
||||
selectInstanceTab(result.instanceId)
|
||||
setShowFolderSelection(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
instanceId: result.instanceId,
|
||||
port: instances().get(result.instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
|
|
@ -408,12 +416,8 @@ const App: Component = () => {
|
|||
return
|
||||
}
|
||||
|
||||
const parentSessionId = session.parentId ?? session.id
|
||||
const parentSession = sessions.find((s) => s.id === parentSessionId)
|
||||
|
||||
if (!parentSession || parentSession.parentId !== null) {
|
||||
return
|
||||
}
|
||||
const parentSession = getSessionRoot(instanceId, sessionId)
|
||||
if (!parentSession) return
|
||||
|
||||
clearActiveParentSession(instanceId)
|
||||
|
||||
|
|
@ -500,7 +504,7 @@ const App: Component = () => {
|
|||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
|
||||
tauriBridge.event.listen("menu:newInstance", () => {
|
||||
handleNewInstanceRequest()
|
||||
}).then((unlisten) => {
|
||||
|
|
@ -542,7 +546,7 @@ const App: Component = () => {
|
|||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||
|
|
@ -667,7 +671,7 @@ const App: Component = () => {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
<SettingsScreen />
|
||||
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
|
||||
<Show when={alreadyOpenFolderChoice()}>
|
||||
|
|
@ -694,7 +698,7 @@ const App: Component = () => {
|
|||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</Show>
|
||||
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
<Toaster
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||
import type { Session } from "../../../types/session"
|
||||
import {
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setActiveSessionFromList,
|
||||
} from "../../../stores/sessions"
|
||||
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||
import { getBackgroundProcesses } from "../../../stores/background-processes"
|
||||
|
|
@ -131,21 +131,8 @@ export function useInstanceSessionContext(options: InstanceSessionContextOptions
|
|||
return
|
||||
}
|
||||
|
||||
const session = allInstanceSessions().get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(instanceId, parentId)
|
||||
setActiveSession(instanceId, sessionId)
|
||||
})
|
||||
if (!allInstanceSessions().has(sessionId)) return
|
||||
setActiveSessionFromList(instanceId, sessionId)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../s
|
|||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { ensureSessionAncestorsExpanded, sessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { selectInstanceTab } from "../stores/app-tabs"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
|
|
@ -33,7 +33,6 @@ function DeleteUpToIcon() {
|
|||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const NO_STEP_BORDER = "none"
|
||||
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
|
|
@ -136,11 +135,8 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
|
|||
|
||||
function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
selectInstanceTab(location.instanceId)
|
||||
const parentToActivate = location.parentId ?? location.sessionId
|
||||
setActiveParentSession(location.instanceId, parentToActivate)
|
||||
if (location.parentId) {
|
||||
setActiveSession(location.instanceId, location.sessionId)
|
||||
}
|
||||
ensureSessionAncestorsExpanded(location.instanceId, location.sessionId)
|
||||
setActiveSessionFromList(location.instanceId, location.sessionId)
|
||||
}
|
||||
|
||||
interface CachedBlockEntry {
|
||||
|
|
@ -642,6 +638,7 @@ interface MessageBlockProps {
|
|||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
selectedMessageIds?: () => Set<string>
|
||||
selectedToolPartKeys?: () => Set<string>
|
||||
selectedCompanionPartKeys?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onRevert?: (messageId: string) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
|
|
@ -661,8 +658,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||
const isDeleteMessageHovered = () => {
|
||||
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||
|
||||
const selected = props.selectedMessageIds?.() ?? new Set<string>()
|
||||
if (selected.has(props.messageId)) {
|
||||
if (props.selectedMessageIds?.().has(props.messageId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -966,6 +962,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
selectedCompanionPartKeys={props.selectedCompanionPartKeys}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
/>
|
||||
</Match>
|
||||
|
|
@ -983,6 +980,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
selectedCompanionPartKeys={props.selectedCompanionPartKeys}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
|
|
@ -1016,6 +1014,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
selectedMessageIds={props.selectedMessageIds}
|
||||
selectedCompanionPartKeys={props.selectedCompanionPartKeys}
|
||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||
onContentRendered={props.onContentRendered}
|
||||
forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId}
|
||||
|
|
@ -1044,6 +1043,7 @@ interface StepCardProps {
|
|||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
selectedCompanionPartKeys?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
|
@ -1172,6 +1172,14 @@ function StepCard(props: StepCardProps) {
|
|||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
|
||||
const isSelectedCompanion = () => {
|
||||
const partId = (props.part as { id?: unknown })?.id
|
||||
return Boolean(
|
||||
props.messageId &&
|
||||
typeof partId === "string" &&
|
||||
props.selectedCompanionPartKeys?.().has(`${props.messageId}:${partId}`),
|
||||
)
|
||||
}
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
const date = new Date(value)
|
||||
|
|
@ -1201,7 +1209,7 @@ function StepCard(props: StepCardProps) {
|
|||
}
|
||||
const info = props.messageInfo
|
||||
const part = props.part as any
|
||||
|
||||
|
||||
// step-finish parts have tokens embedded; also check messageInfo
|
||||
const partTokens = part?.tokens
|
||||
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
|
||||
|
|
@ -1209,7 +1217,7 @@ function StepCard(props: StepCardProps) {
|
|||
if (!tokens) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
input: tokens.input ?? 0,
|
||||
output: tokens.output ?? 0,
|
||||
|
|
@ -1336,7 +1344,11 @@ function StepCard(props: StepCardProps) {
|
|||
return null
|
||||
}
|
||||
return (
|
||||
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
|
||||
<div
|
||||
class="delete-hover-scope message-step-card message-step-finish message-step-finish-flush relative"
|
||||
style={finishStyle()}
|
||||
data-delete-part-hover={isSelectedCompanion() ? "true" : undefined}
|
||||
>
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<ActionOverflowMenu
|
||||
|
|
@ -1401,6 +1413,7 @@ interface ReasoningCardProps {
|
|||
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||
selectedMessageIds?: () => Set<string>
|
||||
selectedCompanionPartKeys?: () => Set<string>
|
||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||
onContentRendered?: () => void
|
||||
forceExpanded?: boolean
|
||||
|
|
@ -1419,7 +1432,6 @@ function ReasoningStreamOutput(props: {
|
|||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "reasoning-scroll-sentinel",
|
||||
})
|
||||
|
||||
|
|
@ -1480,6 +1492,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
const isSelectedCompanion = () => {
|
||||
const partId = (props.part as { id?: unknown })?.id
|
||||
return Boolean(
|
||||
typeof partId === "string" &&
|
||||
props.selectedCompanionPartKeys?.().has(`${props.messageId}:${partId}`),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
|
|
@ -1699,6 +1718,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||
return (
|
||||
<div
|
||||
class="delete-hover-scope message-reasoning-card"
|
||||
data-delete-part-hover={isSelectedCompanion() ? "true" : undefined}
|
||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||
>
|
||||
<div class="message-reasoning-header">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state"
|
|||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId } from "./message-anchors"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
|
||||
import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
|
||||
import { isSnapshotAutoFollowing } from "./virtual-follow-behavior"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
|
|
@ -23,8 +23,8 @@ import { getPartCharCount } from "../lib/token-utils"
|
|||
import { buildSessionSearchMatches } from "../lib/session-search"
|
||||
import type { SessionSearchMatch } from "../lib/session-search"
|
||||
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
|
||||
import { collectToolDeletionCompanionPartIds, executeBulkDeletionPlan } from "./tool-deletion-companions"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||
|
|
@ -49,7 +49,8 @@ export interface MessageSectionProps {
|
|||
onReloadMessages?: () => void
|
||||
isActive?: boolean
|
||||
sessionStreamingActive?: boolean
|
||||
bottomFollowIntent?: VirtualFollowBottomIntent | null
|
||||
explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null
|
||||
onExplicitBottomPinCancelled?: () => void
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
|
|
@ -502,6 +503,34 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
}
|
||||
return set
|
||||
})
|
||||
const deleteCompanionParts = createMemo(() => {
|
||||
sessionRevision()
|
||||
const selectedByMessage = new Map<string, Set<string>>()
|
||||
for (const entry of deleteToolParts()) {
|
||||
const selected = selectedByMessage.get(entry.messageId) ?? new Set<string>()
|
||||
selected.add(entry.partId)
|
||||
selectedByMessage.set(entry.messageId, selected)
|
||||
}
|
||||
|
||||
const companions: { messageId: string; partId: string }[] = []
|
||||
const s = store()
|
||||
for (const [messageId, selectedToolPartIds] of selectedByMessage) {
|
||||
const record = s.getMessage(messageId)
|
||||
if (!record) continue
|
||||
const partIds = collectToolDeletionCompanionPartIds(
|
||||
record.partIds ?? [],
|
||||
(partId) => record.parts?.[partId]?.data,
|
||||
selectedToolPartIds,
|
||||
)
|
||||
for (const partId of partIds) {
|
||||
companions.push({ messageId, partId })
|
||||
}
|
||||
}
|
||||
return companions
|
||||
})
|
||||
const deleteCompanionPartKeys = createMemo(() =>
|
||||
new Set(deleteCompanionParts().map((entry) => `${entry.messageId}:${entry.partId}`)),
|
||||
)
|
||||
const isDeleteMode = createMemo(() => deleteMessageIds().size > 0 || deleteToolParts().length > 0)
|
||||
const selectedDeleteCount = createMemo(() => deleteMessageIds().size + deleteToolParts().length)
|
||||
|
||||
|
|
@ -551,6 +580,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
}
|
||||
total += Math.max(Math.round(chars / 4), 1)
|
||||
}
|
||||
for (const { messageId, partId } of deleteCompanionParts()) {
|
||||
const part = s.getMessage(messageId)?.parts?.[partId]?.data
|
||||
if (part) total += Math.max(Math.round(getPartCharCount(part) / 4), 1)
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
|
@ -628,15 +661,22 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const companionParts = deleteCompanionParts()
|
||||
|
||||
try {
|
||||
for (const messageId of toDelete) {
|
||||
await deleteMessage(props.instanceId, props.sessionId, messageId)
|
||||
}
|
||||
for (const { messageId, partId } of toolParts) {
|
||||
if (!allowed.has(messageId)) continue
|
||||
await deleteMessagePart(props.instanceId, props.sessionId, messageId, partId)
|
||||
}
|
||||
clearDeleteMode()
|
||||
await executeBulkDeletionPlan(
|
||||
{
|
||||
messageIds: toDelete,
|
||||
companionParts: companionParts.filter(({ messageId }) => allowed.has(messageId)),
|
||||
toolParts: toolParts.filter(({ messageId }) => allowed.has(messageId)),
|
||||
},
|
||||
{
|
||||
clearSelection: clearDeleteMode,
|
||||
deleteMessage: (messageId) => deleteMessage(props.instanceId, props.sessionId, messageId),
|
||||
deletePart: ({ messageId, partId }) =>
|
||||
deleteMessagePart(props.instanceId, props.sessionId, messageId, partId),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
|
||||
title: t("messageSection.bulkDelete.failedTitle"),
|
||||
|
|
@ -735,16 +775,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
return
|
||||
}
|
||||
|
||||
const element = streamElement()
|
||||
if (!allowCapture || !canCapture) return
|
||||
if (!element) return
|
||||
const scrollTop = element.scrollTop
|
||||
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0
|
||||
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
|
||||
const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom }
|
||||
setLastGoodScrollSnapshot(sessionId, snapshot)
|
||||
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot)
|
||||
}
|
||||
|
||||
// Persist scroll position when switching sessions. This effect's cleanup runs
|
||||
|
|
@ -783,7 +813,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
setScrollControlsOpen(false)
|
||||
}
|
||||
|
||||
function openScrollControlsFromTrigger(event: PointerEvent) {
|
||||
function openScrollControlsFromTrigger(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (scrollControlsOpen()) return
|
||||
|
|
@ -795,7 +825,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action()
|
||||
setScrollControlsHoverSuppressed(event.pointerType !== "mouse")
|
||||
setScrollControlsHoverSuppressed(false)
|
||||
closeScrollControls()
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +866,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const api = listApi()
|
||||
if (!api) return
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true }))
|
||||
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true }))
|
||||
onCleanup(() => props.registerScrollToBottom?.(null))
|
||||
}
|
||||
})
|
||||
|
|
@ -1264,7 +1294,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
if (!match || !isSearchOpen()) return
|
||||
if (match.id === lastScrolledSearchMatchId) return
|
||||
lastScrolledSearchMatchId = match.id
|
||||
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false })
|
||||
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" })
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -1367,8 +1397,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
overscanPx={800}
|
||||
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
streamingActive={streamingActive}
|
||||
isActive={isActive}
|
||||
scrollToBottomOnActivate={() => false}
|
||||
|
|
@ -1376,7 +1404,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
forceBottomFollowIntent={() => props.bottomFollowIntent ?? null}
|
||||
explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null}
|
||||
onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled}
|
||||
autoPinHoldEnabled={holdLongAssistantRepliesEnabled}
|
||||
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||
|
|
@ -1429,7 +1458,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="message-scroll-button message-scroll-controls-trigger"
|
||||
onPointerUp={openScrollControlsFromTrigger}
|
||||
onClick={openScrollControlsFromTrigger}
|
||||
aria-label={t("messageSection.scroll.showControlsAriaLabel")}
|
||||
title={t("messageSection.scroll.showControlsAriaLabel")}
|
||||
>
|
||||
|
|
@ -1472,7 +1501,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom({ suppressHold: true }))}
|
||||
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom())}
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
|
|
@ -1561,6 +1590,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
onDeleteHoverChange={setDeleteHover}
|
||||
selectedMessageIds={selectedForDeletion}
|
||||
selectedToolPartKeys={deleteToolPartKeys}
|
||||
selectedCompanionPartKeys={deleteCompanionPartKeys}
|
||||
onToggleSelectedMessage={setMessageSelectedForDeletion}
|
||||
onRevert={props.onRevert}
|
||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
getQuestionEnqueuedAtForInstance,
|
||||
sendPermissionResponse,
|
||||
} from "../stores/instances"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants"
|
||||
|
||||
|
|
@ -262,10 +262,8 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||
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)
|
||||
if (sessionStateSessions().get(props.instanceId)?.has(sessionId)) {
|
||||
ensureSessionAncestorsExpanded(props.instanceId, sessionId)
|
||||
}
|
||||
|
||||
setActiveSessionFromList(props.instanceId, sessionId)
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ import { useI18n } from "../lib/i18n"
|
|||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import {
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
ensureSessionAncestorsExpanded,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
isSessionExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
renameSession,
|
||||
sessions as sessionStateSessions,
|
||||
setActiveSessionFromList,
|
||||
toggleSessionParentExpanded,
|
||||
toggleSessionExpanded,
|
||||
loadMoreSessions,
|
||||
searchSessions,
|
||||
getSessionHasMore,
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
isSessionSearchLoading,
|
||||
} from "../stores/sessions"
|
||||
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
|
||||
import { collectSessionThreadIds, findSessionThread, sortSessionIdsDeepestFirst } from "../stores/session-tree"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
|
@ -150,6 +151,16 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
return sessionId.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => {
|
||||
const matchingChildren: SessionThread[] = []
|
||||
for (const child of thread.children) {
|
||||
const filteredChild = filterThreadTree(child, query)
|
||||
if (filteredChild !== null) matchingChildren.push(filteredChild)
|
||||
}
|
||||
if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null
|
||||
return { ...thread, children: matchingChildren }
|
||||
}
|
||||
|
||||
const filteredThreads = createMemo<SessionThread[]>(() => {
|
||||
const query = normalizedQuery()
|
||||
if (!query) return props.threads
|
||||
|
|
@ -160,29 +171,23 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
return getSessionSearchThreads(props.instanceId)
|
||||
}
|
||||
|
||||
const next: SessionThread[] = []
|
||||
const result: SessionThread[] = []
|
||||
for (const thread of props.threads) {
|
||||
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
|
||||
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
|
||||
|
||||
if (!parentMatches && matchingChildren.length === 0) continue
|
||||
|
||||
next.push({
|
||||
parent: thread.parent,
|
||||
children: matchingChildren,
|
||||
latestUpdated: thread.latestUpdated,
|
||||
})
|
||||
const filtered = filterThreadTree(thread, query)
|
||||
if (filtered !== null) result.push(filtered)
|
||||
}
|
||||
|
||||
return next
|
||||
return result
|
||||
})
|
||||
|
||||
const allMatchingSessionIds = createMemo<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
for (const thread of filteredThreads()) {
|
||||
ids.push(thread.parent.id)
|
||||
for (const child of thread.children) ids.push(child.id)
|
||||
const collectIds = (threads: SessionThread[]) => {
|
||||
for (const thread of threads) {
|
||||
ids.push(thread.session.id)
|
||||
collectIds(thread.children)
|
||||
}
|
||||
}
|
||||
collectIds(filteredThreads())
|
||||
return ids
|
||||
})
|
||||
|
||||
|
|
@ -206,14 +211,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
// If the user selects a child session, make sure its parent thread is expanded.
|
||||
// For parent sessions we don't force expansion; user can collapse/expand freely.
|
||||
if (session?.parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, session.parentId)
|
||||
ensureSessionAncestorsExpanded(props.instanceId, session.id)
|
||||
}
|
||||
|
||||
props.onSelect(sessionId)
|
||||
|
|
@ -360,21 +364,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
})
|
||||
}
|
||||
|
||||
const getSelectableThreadIds = (parentId: string): string[] => {
|
||||
const query = normalizedQuery()
|
||||
const source = query ? filteredThreads() : props.threads
|
||||
const thread = source.find((t) => t.parent.id === parentId)
|
||||
if (!thread) return [parentId]
|
||||
return [thread.parent.id, ...thread.children.map((c) => c.id)]
|
||||
const getSelectableThreadIds = (sessionId: string): string[] => {
|
||||
const source = normalizedQuery() ? filteredThreads() : props.threads
|
||||
const thread = findSessionThread(source, sessionId)
|
||||
return thread ? collectSessionThreadIds([thread]) : [sessionId]
|
||||
}
|
||||
|
||||
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
|
||||
const ids: string[] = []
|
||||
threads.forEach((thread) => {
|
||||
ids.push(thread.parent.id)
|
||||
thread.children.forEach((child) => ids.push(child.id))
|
||||
})
|
||||
return ids
|
||||
return collectSessionThreadIds(threads)
|
||||
}
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
|
|
@ -433,8 +430,9 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const deletionOrder = sortSessionIdsDeepestFirst(sessionStateSessions().get(props.instanceId) ?? new Map(), selected)
|
||||
let failed = 0
|
||||
for (const sessionId of selected) {
|
||||
for (const sessionId of deletionOrder) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
|
|
@ -457,37 +455,34 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SessionRow: Component<{
|
||||
sessionId: string
|
||||
isChild?: boolean
|
||||
isLastChild?: boolean
|
||||
hasChildren?: boolean
|
||||
session: SessionThread["session"]
|
||||
depth: number
|
||||
isLastChild: boolean
|
||||
hasChildren: boolean
|
||||
expanded?: boolean
|
||||
onToggleExpand?: () => void
|
||||
}> = (rowProps) => {
|
||||
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
|
||||
if (!session()) {
|
||||
return <></>
|
||||
}
|
||||
const sessionId = () => rowProps.session.id
|
||||
const isChild = () => rowProps.depth > 0
|
||||
|
||||
const worktreeSlug = createMemo(() => {
|
||||
if (rowProps.isChild) return "root"
|
||||
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
|
||||
if (isChild()) return "root"
|
||||
return getWorktreeSlugForParentSession(props.instanceId, sessionId())
|
||||
})
|
||||
|
||||
const showWorktreeBadge = createMemo(() => {
|
||||
if (rowProps.isChild) return false
|
||||
if (isChild()) return false
|
||||
if (getGitRepoStatus(props.instanceId) === false) return false
|
||||
const slug = worktreeSlug()
|
||||
return Boolean(slug) && slug !== "root"
|
||||
})
|
||||
|
||||
const isActive = () => props.activeSessionId === rowProps.sessionId
|
||||
const title = () => session()?.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
|
||||
const isActive = () => props.activeSessionId === sessionId()
|
||||
const title = () => rowProps.session.title || t("sessionList.session.untitled")
|
||||
const status = () => getSessionStatus(props.instanceId, sessionId())
|
||||
const retry = () => getSessionRetry(props.instanceId, sessionId())
|
||||
const statusLabel = () => {
|
||||
const retryState = retry()
|
||||
if (retryState) {
|
||||
|
|
@ -503,20 +498,20 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
return t("sessionList.status.idle")
|
||||
}
|
||||
}
|
||||
const needsPermission = () => Boolean(session()?.pendingPermission)
|
||||
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
|
||||
const needsPermission = () => Boolean(rowProps.session.pendingPermission)
|
||||
const needsQuestion = () => Boolean((rowProps.session as any)?.pendingQuestion)
|
||||
const needsInput = () => needsPermission() || needsQuestion()
|
||||
const statusClassName = () => {
|
||||
if (needsInput()) return "session-permission"
|
||||
const base = `session-${retry() ? "retrying" : status()}`
|
||||
const fadeClass = getSessionIdleFadeClass(props.instanceId, rowProps.sessionId)
|
||||
const fadeClass = getSessionIdleFadeClass(props.instanceId, sessionId())
|
||||
return fadeClass ? `${base} ${fadeClass}` : base
|
||||
}
|
||||
const showStatus = () =>
|
||||
needsInput() ||
|
||||
shouldShowSessionStatus(
|
||||
props.instanceId,
|
||||
rowProps.sessionId,
|
||||
sessionId(),
|
||||
now(),
|
||||
preferences().keepUnseenSubagentIdleStatus,
|
||||
)
|
||||
|
|
@ -535,14 +530,10 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
})
|
||||
}
|
||||
|
||||
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
|
||||
const isSelected = () => selectedSessionIds().has(sessionId())
|
||||
|
||||
const parentGroupState = createMemo(() => {
|
||||
if (rowProps.isChild) {
|
||||
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
|
||||
}
|
||||
|
||||
const ids = getSelectableThreadIds(rowProps.sessionId)
|
||||
const ids = rowProps.hasChildren ? getSelectableThreadIds(sessionId()) : [sessionId()]
|
||||
const selected = selectedSessionIds()
|
||||
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
|
||||
return {
|
||||
|
|
@ -558,12 +549,23 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
|
||||
})
|
||||
|
||||
const nestedStyle = () => {
|
||||
if (!isChild()) return undefined
|
||||
const visualDepth = Math.min(rowProps.depth, 6)
|
||||
const indent = 1.375 + visualDepth * 0.875
|
||||
return {
|
||||
"--session-indent": `${indent}rem`,
|
||||
"--session-connector-offset": `${indent - 0.875}rem`,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
<button
|
||||
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)}
|
||||
class={`session-item-base ${isChild() ? "session-item-nested" : ""} ${isChild() && rowProps.isLastChild ? "session-item-child-last" : ""} ${isChild() ? "session-item-border-assistant session-item-kind-assistant" : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
style={nestedStyle()}
|
||||
data-session-id={sessionId()}
|
||||
onClick={() => selectSession(sessionId())}
|
||||
title={title()}
|
||||
role="button"
|
||||
aria-selected={isActive()}
|
||||
|
|
@ -587,15 +589,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
/>
|
||||
</Show>
|
||||
|
||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||
<Show when={isChild()} fallback={<User class="w-4 h-4 flex-shrink-0" />}>
|
||||
<Bot class="w-4 h-4 flex-shrink-0" />
|
||||
</Show>
|
||||
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show
|
||||
when={rowProps.hasChildren && !rowProps.isChild}
|
||||
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
|
||||
when={rowProps.hasChildren}
|
||||
fallback={<span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
|
||||
>
|
||||
<span
|
||||
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
|
|
@ -633,7 +637,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
<div class="session-item-actions">
|
||||
<span
|
||||
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, sessionId())}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("sessionList.actions.copyId.ariaLabel")}
|
||||
|
|
@ -643,14 +647,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleReloadSession(event, rowProps.sessionId)}
|
||||
onClick={(event) => handleReloadSession(event, sessionId())}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("sessionList.actions.reload.ariaLabel")}
|
||||
title={t("sessionList.actions.reload.title")}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionReloading(rowProps.sessionId)}
|
||||
when={!isSessionReloading(sessionId())}
|
||||
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
|
||||
>
|
||||
<RotateCw class="w-3 h-3" />
|
||||
|
|
@ -660,7 +664,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openRenameDialog(rowProps.sessionId)
|
||||
openRenameDialog(sessionId())
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -671,14 +675,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||
onClick={(event) => handleDeleteSession(event, sessionId())}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("sessionList.actions.delete.ariaLabel")}
|
||||
title={t("sessionList.actions.delete.title")}
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(rowProps.sessionId)}
|
||||
when={!isSessionDeleting(sessionId())}
|
||||
fallback={
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
|
|
@ -699,28 +703,47 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeParentId = createMemo(() => {
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return null
|
||||
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return null
|
||||
// Recursive component for rendering a thread and its children
|
||||
const SessionThreadRow: Component<{
|
||||
thread: SessionThread
|
||||
depth?: number
|
||||
isLastChild?: boolean
|
||||
}> = (rowProps) => {
|
||||
const depth = () => rowProps.depth ?? 0
|
||||
const expanded = () => normalizedQuery() ? true : isSessionExpanded(props.instanceId, rowProps.thread.session.id)
|
||||
|
||||
return activeSession.parentId ?? activeSession.id
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
session={rowProps.thread.session}
|
||||
depth={depth()}
|
||||
hasChildren={rowProps.thread.hasChildren}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionExpanded(props.instanceId, rowProps.thread.session.id)}
|
||||
isLastChild={Boolean(rowProps.isLastChild)}
|
||||
/>
|
||||
<Show when={expanded() && rowProps.thread.children.length > 0}>
|
||||
<For each={rowProps.thread.children}>
|
||||
{(childThread, index) => (
|
||||
<SessionThreadRow
|
||||
thread={childThread}
|
||||
depth={depth() + 1}
|
||||
isLastChild={index() === rowProps.thread.children.length - 1}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// Keep the active child session visible by ensuring its parent is expanded.
|
||||
// Don't force-expanding when the active session itself is a parent lets users collapse it.
|
||||
const activeId = props.activeSessionId
|
||||
if (!activeId || activeId === "info") return
|
||||
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
|
||||
if (!activeSession) return
|
||||
if (!activeSession.parentId) return
|
||||
const parentId = activeParentId()
|
||||
if (!parentId) return
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
if (!activeSession?.parentId) return
|
||||
ensureSessionAncestorsExpanded(props.instanceId, activeId)
|
||||
})
|
||||
|
||||
const listEl = createSignal<HTMLElement | null>(null)
|
||||
|
|
@ -729,7 +752,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
|
||||
return (CSS as any).escape(value)
|
||||
}
|
||||
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"")
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
|
||||
}
|
||||
|
||||
const scrollActiveIntoView = (sessionId: string) => {
|
||||
|
|
@ -848,33 +871,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||
|
||||
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
|
||||
|
||||
<Show when={filteredThreads().length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={filteredThreads()}>
|
||||
|
||||
{(thread) => {
|
||||
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
|
||||
return (
|
||||
<>
|
||||
<SessionRow
|
||||
sessionId={thread.parent.id}
|
||||
hasChildren={thread.children.length > 0}
|
||||
expanded={expanded()}
|
||||
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
|
||||
/>
|
||||
|
||||
<Show when={expanded() && thread.children.length > 0}>
|
||||
<For each={thread.children}>
|
||||
{(child, index) => (
|
||||
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
<Show when={filteredThreads().length > 0}>
|
||||
<div class="session-section">
|
||||
<For each={filteredThreads()}>
|
||||
{(thread, index) => (
|
||||
<SessionThreadRow
|
||||
thread={thread}
|
||||
depth={0}
|
||||
isLastChild={index() === filteredThreads().length - 1}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={hasMore() || isFetchingSessions()}>
|
||||
<div
|
||||
ref={(el) => setSentinelEl(el)}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent } from "./session-bottom-follow-intent.ts"
|
||||
|
||||
describe("session bottom follow intent", () => {
|
||||
it("only exposes submit follow intent to the matching session", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
|
||||
|
||||
assert.deepEqual(resolveSessionBottomFollowIntent(intent, "session-a"), {
|
||||
token: 3,
|
||||
minItemCount: 12,
|
||||
})
|
||||
assert.equal(resolveSessionBottomFollowIntent(intent, "session-b"), null)
|
||||
})
|
||||
|
||||
it("clears submit follow intent only after the submitted exchange has rendered and stopped streaming", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-b",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { VirtualFollowBottomIntent } from "../virtual-follow-list"
|
||||
|
||||
export interface SessionBottomFollowIntent extends VirtualFollowBottomIntent {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function resolveSessionBottomFollowIntent(
|
||||
intent: SessionBottomFollowIntent | null,
|
||||
sessionId: string,
|
||||
): VirtualFollowBottomIntent | null {
|
||||
if (!intent || intent.sessionId !== sessionId) return null
|
||||
return { token: intent.token, minItemCount: intent.minItemCount }
|
||||
}
|
||||
|
||||
export function shouldClearSessionBottomFollowIntent(
|
||||
intent: SessionBottomFollowIntent | null,
|
||||
state: { sessionId: string; messageCount: number; streamingActive: boolean },
|
||||
) {
|
||||
if (!intent) return false
|
||||
if (intent.sessionId !== state.sessionId) return false
|
||||
return state.messageCount >= (intent.minItemCount ?? 0) && !state.streamingActive
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent } from "./session-bottom-pin-intent.ts"
|
||||
|
||||
describe("session bottom pin intent", () => {
|
||||
it("targets only the queued user prompt when submitting during active streaming", () => {
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, true), 11)
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, false), 12)
|
||||
})
|
||||
|
||||
it("only exposes submit bottom pin intent to the matching session", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
|
||||
|
||||
assert.deepEqual(resolveSessionBottomPinIntent(intent, "session-a"), {
|
||||
token: 3,
|
||||
minItemCount: 12,
|
||||
})
|
||||
assert.equal(resolveSessionBottomPinIntent(intent, "session-b"), null)
|
||||
})
|
||||
|
||||
it("clears submit bottom pin intent after the submitted exchange has rendered and stopped streaming", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-b",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("clears a submitted turn that stopped streaming before the optimistic count was reached", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: true }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { VirtualExplicitBottomPinIntent } from "../virtual-follow-list"
|
||||
|
||||
export interface SessionBottomPinIntent extends VirtualExplicitBottomPinIntent {
|
||||
sessionId: string
|
||||
createdMessageCount: number
|
||||
observedStreaming: boolean
|
||||
}
|
||||
|
||||
export function getSubmitBottomPinTargetCount(messageCount: number, streamingActive: boolean) {
|
||||
return messageCount + (streamingActive ? 1 : 2)
|
||||
}
|
||||
|
||||
export function resolveSessionBottomPinIntent(
|
||||
intent: SessionBottomPinIntent | null,
|
||||
sessionId: string,
|
||||
): VirtualExplicitBottomPinIntent | null {
|
||||
if (!intent || intent.sessionId !== sessionId) return null
|
||||
return { token: intent.token, minItemCount: intent.minItemCount }
|
||||
}
|
||||
|
||||
export function shouldClearSessionBottomPinIntent(
|
||||
intent: SessionBottomPinIntent | null,
|
||||
state: { sessionId: string; messageCount: number; streamingActive: boolean },
|
||||
) {
|
||||
if (!intent) return false
|
||||
if (intent.sessionId !== state.sessionId) return false
|
||||
if (state.streamingActive) return false
|
||||
if (state.messageCount >= (intent.minItemCount ?? 0)) return true
|
||||
return intent.observedStreaming && state.messageCount > intent.createdMessageCount
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import PromptInput from "../prompt-input"
|
|||
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status"
|
||||
import { deleteMessage } from "../../stores/session-actions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
|
|
@ -21,7 +21,7 @@ import { useConfig } from "../../stores/preferences"
|
|||
import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../stores/session-previews"
|
||||
import { SessionPreviewView } from "../session-preview-view"
|
||||
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
|
||||
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent, type SessionBottomFollowIntent } from "./session-bottom-follow-intent"
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
const pendingIdleSeenTimers = new Set<string>()
|
||||
const [submitBottomFollowIntent, setSubmitBottomFollowIntent] = createSignal<SessionBottomFollowIntent | null>(null)
|
||||
let submitBottomFollowIntentSequence = 0
|
||||
const [submitBottomPinIntent, setSubmitBottomPinIntent] = createSignal<SessionBottomPinIntent | null>(null)
|
||||
let submitBottomPinIntentSequence = 0
|
||||
|
||||
function shouldScrollToBottomOnActivate() {
|
||||
const current = session()
|
||||
|
|
@ -105,23 +105,45 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return true
|
||||
}
|
||||
|
||||
function startSubmitBottomFollowIntent(minItemCount: number) {
|
||||
submitBottomFollowIntentSequence += 1
|
||||
setSubmitBottomFollowIntent({ sessionId: props.sessionId, token: submitBottomFollowIntentSequence, minItemCount })
|
||||
function startSubmitBottomPinIntent(
|
||||
minItemCount: number,
|
||||
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
|
||||
) {
|
||||
submitBottomPinIntentSequence += 1
|
||||
const previous = submitBottomPinIntent()
|
||||
const createdMessageCount = options?.createdMessageCount ?? messageStore().getSessionMessageIds(props.sessionId).length
|
||||
const shouldPreserveObservedStreaming = Boolean(
|
||||
options?.preserveObservedStreaming &&
|
||||
previous?.sessionId === props.sessionId &&
|
||||
previous.createdMessageCount === createdMessageCount,
|
||||
)
|
||||
const intent: SessionBottomPinIntent = {
|
||||
sessionId: props.sessionId,
|
||||
token: submitBottomPinIntentSequence,
|
||||
minItemCount,
|
||||
createdMessageCount,
|
||||
observedStreaming: shouldPreserveObservedStreaming ? previous?.observedStreaming === true : false,
|
||||
}
|
||||
setSubmitBottomPinIntent(intent)
|
||||
return intent
|
||||
}
|
||||
|
||||
function forceSubmittedExchangeToBottom(minItemCount: number) {
|
||||
startSubmitBottomFollowIntent(minItemCount)
|
||||
function forceSubmittedExchangeToBottom(
|
||||
minItemCount: number,
|
||||
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
|
||||
) {
|
||||
const intent = startSubmitBottomPinIntent(minItemCount, options)
|
||||
scrollToBottomHandle?.()
|
||||
return intent
|
||||
}
|
||||
|
||||
const activeSubmitBottomFollowIntent = createMemo(() => {
|
||||
const intent = submitBottomFollowIntent()
|
||||
const activeSubmitBottomPinIntent = createMemo(() => {
|
||||
const intent = submitBottomPinIntent()
|
||||
const currentSession = session()
|
||||
if (!intent || !currentSession) return null
|
||||
|
||||
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
|
||||
if (shouldClearSessionBottomFollowIntent(intent, {
|
||||
if (shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: currentSession.id,
|
||||
messageCount,
|
||||
streamingActive: sessionStreamingActive(),
|
||||
|
|
@ -129,7 +151,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return null
|
||||
}
|
||||
|
||||
return resolveSessionBottomFollowIntent(intent, currentSession.id)
|
||||
return resolveSessionBottomPinIntent(intent, currentSession.id)
|
||||
})
|
||||
|
||||
function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> {
|
||||
|
|
@ -141,7 +163,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
|
||||
if (currentSession.parentId === null && !keepUnseenSubagentIdleStatus) {
|
||||
for (const child of props.activeSessions.values()) {
|
||||
if (child.parentId !== currentSession.id) continue
|
||||
if (child.id === currentSession.id) continue
|
||||
if (child.status !== "idle") continue
|
||||
if (typeof child.idleSince !== "number") continue
|
||||
entries.push({ id: child.id, idleSince: child.idleSince })
|
||||
|
|
@ -151,6 +173,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return entries
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sessionId,
|
||||
() => setSubmitBottomPinIntent(null),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
|
|
@ -164,17 +194,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
)
|
||||
|
||||
createEffect(() => {
|
||||
const intent = submitBottomFollowIntent()
|
||||
const intent = submitBottomPinIntent()
|
||||
const currentSession = session()
|
||||
if (!intent || !currentSession) return
|
||||
|
||||
if (sessionStreamingActive() && intent.sessionId === currentSession.id && !intent.observedStreaming) {
|
||||
setSubmitBottomPinIntent({ ...intent, observedStreaming: true })
|
||||
return
|
||||
}
|
||||
|
||||
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
|
||||
if (shouldClearSessionBottomFollowIntent(intent, {
|
||||
if (shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: currentSession.id,
|
||||
messageCount,
|
||||
streamingActive: sessionStreamingActive(),
|
||||
})) {
|
||||
setSubmitBottomFollowIntent(null)
|
||||
setSubmitBottomPinIntent(null)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -309,14 +344,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
const messageCount = messageStore().getSessionMessageIds(props.sessionId).length
|
||||
const submittedExchangeTargetCount = messageCount + 2
|
||||
forceSubmittedExchangeToBottom(submittedExchangeTargetCount)
|
||||
const submittedExchangeTargetCount = getSubmitBottomPinTargetCount(messageCount, sessionStreamingActive())
|
||||
const initialPinIntent = forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
|
||||
try {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length
|
||||
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount))
|
||||
if (latestMessageCount < submittedExchangeTargetCount && !sessionStreamingActive()) {
|
||||
setSubmitBottomPinIntent(null)
|
||||
} else if (submitBottomPinIntent()?.token === initialPinIntent.token) {
|
||||
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount), {
|
||||
createdMessageCount: messageCount,
|
||||
preserveObservedStreaming: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitBottomFollowIntent(null)
|
||||
setSubmitBottomPinIntent(null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -434,11 +476,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
log.error("Failed to rename forked session", error)
|
||||
})
|
||||
|
||||
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
||||
setActiveParentSession(props.instanceId, parentToActivate)
|
||||
if (forkedSession.parentId) {
|
||||
setActiveSession(props.instanceId, forkedSession.id)
|
||||
}
|
||||
ensureSessionAncestorsExpanded(props.instanceId, forkedSession.id)
|
||||
setActiveSessionFromList(props.instanceId, forkedSession.id)
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
|
|
@ -481,7 +520,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
loadError={messagesLoadError()}
|
||||
onReloadMessages={handleReloadMessages}
|
||||
sessionStreamingActive={sessionStreamingActive()}
|
||||
bottomFollowIntent={activeSubmitBottomFollowIntent()}
|
||||
explicitBottomPinIntent={activeSubmitBottomPinIntent()}
|
||||
onExplicitBottomPinCancelled={() => setSubmitBottomPinIntent(null)}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ const log = getLogger("session")
|
|||
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
|
|
@ -194,7 +193,6 @@ function ToolCallDetails(props: {
|
|||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "tool-call-scroll-sentinel",
|
||||
})
|
||||
|
||||
|
|
|
|||
135
packages/ui/src/components/tool-deletion-companions.test.ts
Normal file
135
packages/ui/src/components/tool-deletion-companions.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import {
|
||||
collectToolDeletionCompanionPartIds,
|
||||
executeBulkDeletionPlan,
|
||||
type BulkDeletionPlan,
|
||||
} from "./tool-deletion-companions"
|
||||
|
||||
function collect(parts: Array<{ id: string; type: string }>, selectedToolPartIds: string[]) {
|
||||
const byId = new Map(parts.map((part) => [part.id, part]))
|
||||
return collectToolDeletionCompanionPartIds(
|
||||
parts.map((part) => part.id),
|
||||
(partId) => byId.get(partId),
|
||||
new Set(selectedToolPartIds),
|
||||
)
|
||||
}
|
||||
|
||||
describe("collectToolDeletionCompanionPartIds", () => {
|
||||
it("collects reasoning and step-finish for a selected tool step", () => {
|
||||
const result = collect([
|
||||
{ id: "start", type: "step-start" },
|
||||
{ id: "reasoning", type: "reasoning" },
|
||||
{ id: "tool", type: "tool" },
|
||||
{ id: "finish", type: "step-finish" },
|
||||
{ id: "text", type: "text" },
|
||||
], ["tool"])
|
||||
|
||||
assert.deepEqual([...result], ["reasoning", "finish"])
|
||||
})
|
||||
|
||||
it("keeps shared companions when only one tool in a step is selected", () => {
|
||||
const result = collect([
|
||||
{ id: "reasoning", type: "reasoning" },
|
||||
{ id: "tool-a", type: "tool" },
|
||||
{ id: "tool-b", type: "tool" },
|
||||
{ id: "finish", type: "step-finish" },
|
||||
], ["tool-a"])
|
||||
|
||||
assert.deepEqual([...result], [])
|
||||
})
|
||||
|
||||
it("collects shared companions when every tool in the step is selected", () => {
|
||||
const result = collect([
|
||||
{ id: "reasoning", type: "reasoning" },
|
||||
{ id: "tool-a", type: "tool" },
|
||||
{ id: "tool-b", type: "tool" },
|
||||
{ id: "finish", type: "step-finish" },
|
||||
], ["tool-a", "tool-b"])
|
||||
|
||||
assert.deepEqual([...result], ["reasoning", "finish"])
|
||||
})
|
||||
|
||||
it("does not collect companions from an unselected adjacent step", () => {
|
||||
const result = collect([
|
||||
{ id: "start-a", type: "step-start" },
|
||||
{ id: "reasoning-a", type: "reasoning" },
|
||||
{ id: "tool-a", type: "tool" },
|
||||
{ id: "finish-a", type: "step-finish" },
|
||||
{ id: "start-b", type: "step-start" },
|
||||
{ id: "reasoning-b", type: "reasoning" },
|
||||
{ id: "tool-b", type: "tool" },
|
||||
{ id: "finish-b", type: "step-finish" },
|
||||
], ["tool-a"])
|
||||
|
||||
assert.deepEqual([...result], ["reasoning-a", "finish-a"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeBulkDeletionPlan", () => {
|
||||
const plan: BulkDeletionPlan = {
|
||||
messageIds: ["message"],
|
||||
companionParts: [
|
||||
{ messageId: "assistant", partId: "reasoning" },
|
||||
{ messageId: "assistant", partId: "finish" },
|
||||
],
|
||||
toolParts: [{ messageId: "assistant", partId: "tool" }],
|
||||
}
|
||||
|
||||
it("clears positional selection and deletes companions before tools", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
await executeBulkDeletionPlan(plan, {
|
||||
clearSelection: () => events.push("clear"),
|
||||
deleteMessage: async (messageId) => { events.push(`message:${messageId}`) },
|
||||
deletePart: async ({ partId }) => { events.push(`part:${partId}`) },
|
||||
})
|
||||
|
||||
assert.deepEqual(events, [
|
||||
"clear",
|
||||
"message:message",
|
||||
"part:reasoning",
|
||||
"part:finish",
|
||||
"part:tool",
|
||||
])
|
||||
})
|
||||
|
||||
it("does not delete tools when companion cleanup fails", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
await assert.rejects(
|
||||
executeBulkDeletionPlan(
|
||||
{ ...plan, messageIds: [] },
|
||||
{
|
||||
clearSelection: () => events.push("clear"),
|
||||
deleteMessage: async () => {},
|
||||
deletePart: async ({ partId }) => {
|
||||
events.push(`part:${partId}`)
|
||||
if (partId === "finish") throw new Error("failed companion")
|
||||
},
|
||||
},
|
||||
),
|
||||
/failed companion/,
|
||||
)
|
||||
|
||||
assert.deepEqual(events, ["clear", "part:reasoning", "part:finish"])
|
||||
})
|
||||
|
||||
it("clears selection before a message deletion failure", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
await assert.rejects(
|
||||
executeBulkDeletionPlan(plan, {
|
||||
clearSelection: () => events.push("clear"),
|
||||
deleteMessage: async () => {
|
||||
events.push("message")
|
||||
throw new Error("failed message")
|
||||
},
|
||||
deletePart: async ({ partId }) => { events.push(`part:${partId}`) },
|
||||
}),
|
||||
/failed message/,
|
||||
)
|
||||
|
||||
assert.deepEqual(events, ["clear", "message"])
|
||||
})
|
||||
})
|
||||
74
packages/ui/src/components/tool-deletion-companions.ts
Normal file
74
packages/ui/src/components/tool-deletion-companions.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
type MessagePart = {
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type MessagePartDeletionTarget = {
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
export type BulkDeletionPlan = {
|
||||
messageIds: readonly string[]
|
||||
companionParts: readonly MessagePartDeletionTarget[]
|
||||
toolParts: readonly MessagePartDeletionTarget[]
|
||||
}
|
||||
|
||||
type BulkDeletionActions = {
|
||||
clearSelection: () => void
|
||||
deleteMessage: (messageId: string) => Promise<void>
|
||||
deletePart: (target: MessagePartDeletionTarget) => Promise<void>
|
||||
}
|
||||
|
||||
export async function executeBulkDeletionPlan(plan: BulkDeletionPlan, actions: BulkDeletionActions): Promise<void> {
|
||||
// Timeline segment IDs are positional, so stale selections must not survive
|
||||
// the first successful mutation and point at a different segment.
|
||||
actions.clearSelection()
|
||||
|
||||
for (const messageId of plan.messageIds) {
|
||||
await actions.deleteMessage(messageId)
|
||||
}
|
||||
for (const target of plan.companionParts) {
|
||||
await actions.deletePart(target)
|
||||
}
|
||||
for (const target of plan.toolParts) {
|
||||
await actions.deletePart(target)
|
||||
}
|
||||
}
|
||||
|
||||
export function collectToolDeletionCompanionPartIds(
|
||||
partIds: readonly string[],
|
||||
getPart: (partId: string) => MessagePart | undefined,
|
||||
selectedToolPartIds: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const companions = new Set<string>()
|
||||
let stepPartIds: string[] = []
|
||||
|
||||
const flushStep = () => {
|
||||
const toolPartIds = stepPartIds.filter((partId) => getPart(partId)?.type === "tool")
|
||||
if (toolPartIds.length > 0 && toolPartIds.every((partId) => selectedToolPartIds.has(partId))) {
|
||||
for (const partId of stepPartIds) {
|
||||
const type = getPart(partId)?.type
|
||||
if (type === "reasoning" || type === "step-finish") {
|
||||
companions.add(partId)
|
||||
}
|
||||
}
|
||||
}
|
||||
stepPartIds = []
|
||||
}
|
||||
|
||||
for (const partId of partIds) {
|
||||
const type = getPart(partId)?.type
|
||||
if (type === "step-start") {
|
||||
flushStep()
|
||||
continue
|
||||
}
|
||||
|
||||
stepPartIds.push(partId)
|
||||
if (type === "step-finish") {
|
||||
flushStep()
|
||||
}
|
||||
}
|
||||
|
||||
flushStep()
|
||||
return companions
|
||||
}
|
||||
|
|
@ -2,214 +2,92 @@ import assert from "node:assert/strict"
|
|||
import { describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
BOTTOM_FOLLOW_EPSILON_PX,
|
||||
VirtualScrollController,
|
||||
isAtBottom,
|
||||
isAutoFollowing,
|
||||
isSnapshotAutoFollowing,
|
||||
resolveAutoPinHoldElement,
|
||||
shouldSuspendAutoPinToBottomForHold,
|
||||
restoreFollowModeFromSnapshot,
|
||||
transitionFollowMode,
|
||||
type FollowMode,
|
||||
type ScrollControllerMetrics,
|
||||
} from "./virtual-follow-behavior.ts"
|
||||
|
||||
const userScroll = (direction: "up" | "down" | null, atBottom: boolean, canPinToBottom = false) =>
|
||||
({ type: "user-scroll", direction, atBottom, canPinToBottom }) as const
|
||||
const userScroll = (direction: "up" | "down" | null, atBottom: boolean) =>
|
||||
({ type: "user-scroll", direction, atBottom }) as const
|
||||
|
||||
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600): ScrollControllerMetrics {
|
||||
return {
|
||||
offset,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
sentinelMarginPx: 48,
|
||||
}
|
||||
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600, sentinelMarginPx = BOTTOM_FOLLOW_EPSILON_PX): ScrollControllerMetrics {
|
||||
return { offset, scrollHeight, clientHeight, sentinelMarginPx }
|
||||
}
|
||||
|
||||
describe("virtual follow behavior", () => {
|
||||
it("escapes follow on upward user scroll", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, userScroll("up", false))
|
||||
it("escapes follow on any upward user intent", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, userScroll("up", true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow when escaped user scrolls down above bottom without pin permission", () => {
|
||||
it("does not rejoin follow from downward movement above the exact bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow above bottom even with pin permission", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("rejoins follow when escaped user scrolls to the bottom", () => {
|
||||
it("rejoins follow only at the exact bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when the user scrolls down above bottom", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll("down", false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow for directionless scroll above bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll(null, false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow on upward scroll at bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("up", true, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched for directionless user scroll away from bottom", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll(null, false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("pins content growth while following but not while escaped", () => {
|
||||
const escaped = transitionFollowMode({ type: "escaped" }, { type: "content-grew", canPinToBottom: true })
|
||||
const following = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(escaped.effect, { type: "none" })
|
||||
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true })
|
||||
})
|
||||
|
||||
it("does not align or pin while held content grows", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "content-grew", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("enters hold mode for a valid hold candidate", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, { type: "hold-candidate", key: "message-1", shouldHold: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when the hold target disappears", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: null, canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when a later hold target is reported", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: "message-2", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("explicit bottom jumps leave hold and suppress the next hold", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "jump-bottom", immediate: true, explicit: true })
|
||||
it("does not pin content growth when the integration gate is closed", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: false })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
})
|
||||
|
||||
it("prompt submission overrides a stale hold latch and returns to bottom follow", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
|
||||
const result = controller.jumpBottom(true, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
assert.equal(controller.isAutoFollowing(), true)
|
||||
})
|
||||
|
||||
it("clears an existing hold latch when hold targeting is disabled", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
|
||||
const result = controller.clearHold(true, true, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
})
|
||||
|
||||
it("keeps submitted prompt content growth in bottom-follow after clearing stale hold", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
controller.jumpBottom(true, true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2400), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("ignores stale previous assistant hold target changes after a submit bottom jump", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("previous-assistant-answer", true)
|
||||
controller.jumpBottom(true, true)
|
||||
|
||||
const targetChanged = controller.holdTargetChanged("previous-assistant-answer", true)
|
||||
const contentRendered = controller.contentRendered(metrics(2400), true)
|
||||
|
||||
assert.deepEqual(targetChanged.state.mode, { type: "following" })
|
||||
assert.deepEqual(targetChanged.effect, { type: "none" })
|
||||
assert.deepEqual(contentRendered.state.mode, { type: "following" })
|
||||
assert.deepEqual(contentRendered.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("keeps escaped-mode streaming detached until actual bottom", () => {
|
||||
const suspend = shouldSuspendAutoPinToBottomForHold({
|
||||
externalSuspend: false,
|
||||
activeHoldTargetKey: null,
|
||||
eligibleHoldTargetKey: "streaming-assistant-answer",
|
||||
})
|
||||
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, !suspend))
|
||||
|
||||
assert.equal(suspend, false)
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps auto-pin suspended while a hold target is actively latched", () => {
|
||||
const suspend = shouldSuspendAutoPinToBottomForHold({
|
||||
externalSuspend: false,
|
||||
activeHoldTargetKey: "streaming-assistant-answer",
|
||||
eligibleHoldTargetKey: "streaming-assistant-answer",
|
||||
})
|
||||
it("explicit bottom jumps enter follow mode", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, { type: "jump-bottom", immediate: true, explicit: true })
|
||||
|
||||
const next = transitionFollowMode({ type: "holding", key: "streaming-assistant-answer" }, userScroll("down", false, !suspend))
|
||||
|
||||
assert.equal(suspend, true)
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "streaming-assistant-answer" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true })
|
||||
})
|
||||
|
||||
it("key jumps can opt into follow or escape mode", () => {
|
||||
const follow = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false, followAfter: true })
|
||||
const escape = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true, followAfter: false })
|
||||
it("explicit bottom jumps override stale upward user intent", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2200, false)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
assert.deepEqual(follow.mode, { type: "following" })
|
||||
assert.deepEqual(escape.mode, { type: "escaped" })
|
||||
const jump = controller.jumpBottom(true, true)
|
||||
const observed = controller.observeViewport(metrics(2400), 100, true)
|
||||
|
||||
assert.deepEqual(jump.state.mode, { type: "following" })
|
||||
assert.deepEqual(observed.state.mode, { type: "following" })
|
||||
})
|
||||
|
||||
it("derives auto-follow from modes", () => {
|
||||
it("key jumps always escape follow mode", () => {
|
||||
const fromEscaped = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false })
|
||||
const fromFollowing = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true })
|
||||
|
||||
assert.deepEqual(fromEscaped.mode, { type: "escaped" })
|
||||
assert.deepEqual(fromFollowing.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("derives auto-follow from the two modes", () => {
|
||||
const modes: Array<[FollowMode, boolean]> = [
|
||||
[{ type: "following" }, true],
|
||||
[{ type: "holding", key: "message-1" }, false],
|
||||
[{ type: "escaped" }, false],
|
||||
]
|
||||
|
||||
|
|
@ -218,56 +96,6 @@ describe("virtual follow behavior", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("pins content growth instead of escaping on transient upward render movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2200), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("does not align or pin when held content renders", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2200), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not resume or snap when a held target disappears", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
|
||||
const result = controller.holdTargetChanged(null, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("lets fresh user upward movement escape even during a programmatic window", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("does not escape for owned programmatic upward movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not resume follow on directionless scroll above bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2220, false)
|
||||
|
|
@ -289,18 +117,7 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not magnet to bottom above bottom even with integration pin permission", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2100, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2220), 100, false, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("resumes follow only when downward movement reaches actual bottom", () => {
|
||||
it("resumes follow only when downward movement reaches exact bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2300, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
|
|
@ -311,31 +128,33 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched until downward movement reaches actual bottom", () => {
|
||||
it("lets fresh user upward movement escape even during a programmatic window", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
controller.recordProgrammaticOffset(2100, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const nearBottom = controller.observeViewport(metrics(2220), 100, false, true)
|
||||
|
||||
assert.deepEqual(nearBottom.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(nearBottom.effect, { type: "none" })
|
||||
|
||||
controller.setUserIntent("down", 800)
|
||||
const atBottom = controller.observeViewport(metrics(2400), 200, false, true)
|
||||
|
||||
assert.deepEqual(atBottom.state.mode, { type: "following" })
|
||||
assert.deepEqual(atBottom.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("still escapes follow on upward movement at bottom", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(1200, false)
|
||||
|
||||
const result = controller.observeViewport(metrics(1100), 100, false)
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("keeps fresh upward intent escaped even if a programmatic scroll later moves down to bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2200, false)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2400), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("does not escape for owned programmatic upward movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
|
|
@ -349,33 +168,27 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not pin content growth when the integration gate is closed", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
it("uses a small bottom follow tolerance", () => {
|
||||
const bottomOffset = 2400
|
||||
|
||||
const result = controller.contentRendered(metrics(2400), false)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.1)), false)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX)), true)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset)), true)
|
||||
})
|
||||
|
||||
it("blocks pre-pin upward reconciliation while restoring", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setRestoring(true)
|
||||
it("treats fractional distance inside the tolerance as at-bottom", () => {
|
||||
const bottomOffset = 2400
|
||||
|
||||
const result = controller.beforeBottomPin(metrics(2200))
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.5)), false)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - 0.5)), true)
|
||||
})
|
||||
|
||||
it("distinguishes close-to-bottom from at-bottom metrics", () => {
|
||||
const closeButNotAtBottom = metrics(2351)
|
||||
|
||||
assert.equal(isAtBottom(closeButNotAtBottom), false)
|
||||
it("does not restore follow from an off-bottom snapshot", () => {
|
||||
assert.equal(isSnapshotAutoFollowing({ atBottom: false, followModeType: "following" }), false)
|
||||
assert.deepEqual(restoreFollowModeFromSnapshot({ atBottom: false, followModeType: "following" }), { type: "escaped" })
|
||||
})
|
||||
|
||||
it("excludes reasoning-only hold targets while preserving Assistant text eligibility", () => {
|
||||
it("keeps hold element resolution as a DOM concern", () => {
|
||||
const itemWrapper = { id: "message-wrapper" } as unknown as HTMLElement
|
||||
const assistantAnswerText = { id: "assistant-answer-text" } as unknown as HTMLElement
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
export type FollowMode =
|
||||
| { type: "following" }
|
||||
| { type: "escaped" }
|
||||
| { type: "holding"; key: string }
|
||||
export type FollowMode = { type: "following" } | { type: "escaped" }
|
||||
|
||||
export const BOTTOM_FOLLOW_EPSILON_PX = 48
|
||||
|
||||
export type FollowEffect =
|
||||
| { type: "none" }
|
||||
| { type: "scroll-top"; immediate: boolean }
|
||||
| { type: "scroll-bottom"; immediate: boolean; suppressHold: boolean }
|
||||
| { type: "scroll-bottom"; immediate: boolean }
|
||||
| { type: "scroll-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
|
||||
| { type: "align-hold"; key: string }
|
||||
|
||||
export type FollowEvent =
|
||||
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean; canPinToBottom: boolean }
|
||||
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean }
|
||||
| { type: "jump-top"; immediate: boolean }
|
||||
| { type: "jump-bottom"; immediate: boolean; explicit: boolean }
|
||||
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean; followAfter: boolean }
|
||||
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
|
||||
| { type: "content-grew"; canPinToBottom: boolean }
|
||||
| { type: "hold-candidate"; key: string; shouldHold: boolean }
|
||||
| { type: "hold-target-changed"; key: string | null; canPinToBottom: boolean }
|
||||
| { type: "clear-hold"; follow: boolean; canPinToBottom: boolean; suppressHold: boolean }
|
||||
| { type: "set-follow"; enabled: boolean }
|
||||
| { type: "reset"; follow: boolean }
|
||||
|
||||
|
|
@ -61,8 +56,6 @@ export interface ScrollControllerSnapshot {
|
|||
|
||||
export interface FollowSnapshotState {
|
||||
followModeType?: FollowMode["type"]
|
||||
heldKey?: string
|
||||
holdAnchorSuspended?: boolean
|
||||
}
|
||||
|
||||
export type HoldTargetElementResolver = (itemWrapper: HTMLElement, key: string) => HTMLElement | null | undefined
|
||||
|
|
@ -73,10 +66,6 @@ export function isAutoFollowing(mode: FollowMode) {
|
|||
return mode.type === "following"
|
||||
}
|
||||
|
||||
export function getHeldKey(mode: FollowMode) {
|
||||
return mode.type === "holding" ? mode.key : null
|
||||
}
|
||||
|
||||
export function resolveAutoPinHoldElement(
|
||||
itemWrapper: HTMLElement | null | undefined,
|
||||
key: string,
|
||||
|
|
@ -89,79 +78,25 @@ export function resolveAutoPinHoldElement(
|
|||
return resolved === undefined ? itemWrapper : resolved
|
||||
}
|
||||
|
||||
export function shouldSuspendAutoPinToBottomForHold(state: {
|
||||
externalSuspend: boolean
|
||||
activeHoldTargetKey: string | null
|
||||
eligibleHoldTargetKey?: string | null
|
||||
}) {
|
||||
return state.externalSuspend || state.activeHoldTargetKey !== null
|
||||
}
|
||||
|
||||
export function shouldTrackHeldAnchor(state: {
|
||||
activeHoldTargetKey: string | null
|
||||
eligibleHoldTargetKey: string | null
|
||||
suspendedByUser: boolean
|
||||
}) {
|
||||
return !state.suspendedByUser && state.activeHoldTargetKey !== null && state.activeHoldTargetKey === state.eligibleHoldTargetKey
|
||||
}
|
||||
|
||||
export function isSnapshotAutoFollowing(snapshot: { atBottom: boolean; followModeType?: FollowMode["type"] } | null | undefined) {
|
||||
if (!snapshot) return true
|
||||
if (snapshot.followModeType) return snapshot.followModeType === "following"
|
||||
return snapshot.atBottom
|
||||
return snapshot.atBottom && snapshot.followModeType !== "escaped"
|
||||
}
|
||||
|
||||
export function getFollowSnapshotState(mode: FollowMode, holdAnchorSuspended: boolean): FollowSnapshotState {
|
||||
if (mode.type === "holding") {
|
||||
return {
|
||||
followModeType: "holding",
|
||||
heldKey: mode.key,
|
||||
holdAnchorSuspended: holdAnchorSuspended || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFollowSnapshotState(mode: FollowMode): FollowSnapshotState {
|
||||
return { followModeType: mode.type }
|
||||
}
|
||||
|
||||
export function restoreFollowModeFromSnapshot(state: {
|
||||
atBottom: boolean
|
||||
followModeType?: FollowMode["type"]
|
||||
heldKey?: string
|
||||
hasHeldKeyItem?: boolean
|
||||
}): FollowMode {
|
||||
if (state.followModeType === "holding" && state.heldKey && state.hasHeldKeyItem) {
|
||||
return { type: "holding", key: state.heldKey }
|
||||
}
|
||||
if (state.followModeType === "following") {
|
||||
return { type: "following" }
|
||||
}
|
||||
if (state.followModeType === "escaped") {
|
||||
return { type: "escaped" }
|
||||
}
|
||||
export function restoreFollowModeFromSnapshot(state: { atBottom: boolean; followModeType?: FollowMode["type"] }): FollowMode {
|
||||
if (state.followModeType === "escaped") return { type: "escaped" }
|
||||
return state.atBottom ? { type: "following" } : { type: "escaped" }
|
||||
}
|
||||
|
||||
export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition {
|
||||
switch (event.type) {
|
||||
case "user-scroll": {
|
||||
if (mode.type === "holding") {
|
||||
if (event.atBottom && event.direction !== "up") {
|
||||
return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
if (event.direction === "up") {
|
||||
return { mode: { type: "escaped" }, effect: noFollowEffect }
|
||||
}
|
||||
if (mode.type === "escaped" && event.direction === "down" && event.atBottom && event.canPinToBottom) {
|
||||
return {
|
||||
mode: { type: "following" },
|
||||
effect: { type: "scroll-bottom", immediate: true, suppressHold: false },
|
||||
}
|
||||
}
|
||||
if (event.atBottom) {
|
||||
return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
}
|
||||
if (event.direction === "up") return { mode: { type: "escaped" }, effect: noFollowEffect }
|
||||
if (event.atBottom) return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
|
||||
|
|
@ -171,42 +106,21 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
|
|||
case "jump-bottom":
|
||||
return {
|
||||
mode: { type: "following" },
|
||||
effect: { type: "scroll-bottom", immediate: event.immediate, suppressHold: event.explicit },
|
||||
effect: { type: "scroll-bottom", immediate: event.immediate },
|
||||
}
|
||||
|
||||
case "jump-key":
|
||||
return {
|
||||
mode: event.followAfter ? { type: "following" } : { type: "escaped" },
|
||||
mode: { type: "escaped" },
|
||||
effect: { type: "scroll-key", key: event.key, block: event.block, smooth: event.smooth },
|
||||
}
|
||||
|
||||
case "content-grew":
|
||||
if (mode.type === "following" && event.canPinToBottom) {
|
||||
return { mode, effect: { type: "scroll-bottom", immediate: true, suppressHold: false } }
|
||||
return { mode, effect: { type: "scroll-bottom", immediate: true } }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "hold-candidate":
|
||||
if (mode.type === "following" && event.shouldHold) {
|
||||
return { mode: { type: "holding", key: event.key }, effect: { type: "align-hold", key: event.key } }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "hold-target-changed":
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "clear-hold":
|
||||
if (mode.type !== "holding") {
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
return {
|
||||
mode: event.follow ? { type: "following" } : { type: "escaped" },
|
||||
effect:
|
||||
event.follow && event.canPinToBottom
|
||||
? { type: "scroll-bottom", immediate: true, suppressHold: event.suppressHold }
|
||||
: noFollowEffect,
|
||||
}
|
||||
|
||||
case "set-follow":
|
||||
return { mode: event.enabled ? { type: "following" } : { type: "escaped" }, effect: noFollowEffect }
|
||||
|
||||
|
|
@ -245,10 +159,6 @@ export class VirtualScrollController {
|
|||
return isAutoFollowing(this.state.mode)
|
||||
}
|
||||
|
||||
heldKey() {
|
||||
return this.state.mode.type === "holding" ? this.state.mode.key : null
|
||||
}
|
||||
|
||||
setUserIntent(direction: ScrollDirection, until: number) {
|
||||
this.state.userIntentDirection = direction
|
||||
this.state.userIntentUntil = until
|
||||
|
|
@ -293,42 +203,22 @@ export class VirtualScrollController {
|
|||
}
|
||||
|
||||
jumpBottom(immediate: boolean, explicit: boolean): ScrollControllerResult {
|
||||
if (explicit) {
|
||||
this.state.userIntentDirection = null
|
||||
this.state.userIntentUntil = 0
|
||||
}
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-bottom", immediate, explicit })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean, followAfter: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth, followAfter })
|
||||
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
holdCandidate(key: string, shouldHold: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "hold-candidate", key, shouldHold })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
holdTargetChanged(key: string | null, canPinToBottom: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "hold-target-changed", key, canPinToBottom })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
clearHold(follow: boolean, canPinToBottom: boolean, suppressHold: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "clear-hold", follow, canPinToBottom, suppressHold })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
observeViewport(
|
||||
metrics: ScrollControllerMetrics,
|
||||
now: number,
|
||||
programmatic: boolean,
|
||||
canPinToBottom = false,
|
||||
forceEscapeFromUpScroll = false,
|
||||
): ScrollControllerResult {
|
||||
observeViewport(metrics: ScrollControllerMetrics, now: number, programmatic: boolean): ScrollControllerResult {
|
||||
const previousOffset = this.state.lastObservedOffset
|
||||
const offset = metrics.offset
|
||||
const scrolledUp = offset < previousOffset - 1
|
||||
|
|
@ -340,66 +230,39 @@ export class VirtualScrollController {
|
|||
this.clearExpiredUserIntent(now)
|
||||
|
||||
const hasFreshIntent = now <= this.state.userIntentUntil
|
||||
if (
|
||||
scrolledUp &&
|
||||
this.isAutoFollowing() &&
|
||||
(forceEscapeFromUpScroll || !atBottom) &&
|
||||
this.heldKey() === null &&
|
||||
(!programmatic || hasFreshIntent)
|
||||
) {
|
||||
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
|
||||
if (hasFreshIntent && this.state.userIntentDirection === "up") {
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
|
||||
const direction = actualDirection ?? this.state.userIntentDirection
|
||||
|
||||
if (!hasFreshIntent && (!actualDirection || programmatic)) {
|
||||
return this.result(noFollowEffect)
|
||||
}
|
||||
|
||||
const direction = actualDirection ?? this.state.userIntentDirection
|
||||
const canMagnetToBottom = hasFreshIntent && direction === "down" && canPinToBottom
|
||||
const next = transitionFollowMode(this.state.mode, {
|
||||
type: "user-scroll",
|
||||
direction,
|
||||
atBottom,
|
||||
canPinToBottom: canMagnetToBottom,
|
||||
})
|
||||
if (direction === "up" && (!programmatic || hasFreshIntent)) {
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
const next = transitionFollowMode(this.state.mode, { type: "user-scroll", direction, atBottom })
|
||||
this.state.mode = next.mode
|
||||
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
contentRendered(metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
|
||||
contentRendered(_metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
|
||||
if (this.state.restoring) return this.result(noFollowEffect)
|
||||
if (!canPinToBottom || !this.isAutoFollowing()) {
|
||||
const reconcile = this.reconcileUpwardDomMovement(metrics)
|
||||
if (reconcile.effect.type !== "none") return reconcile
|
||||
}
|
||||
|
||||
const next = transitionFollowMode(this.state.mode, { type: "content-grew", canPinToBottom })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
beforeBottomPin(metrics: ScrollControllerMetrics): ScrollControllerResult {
|
||||
if (this.state.restoring) return this.result(noFollowEffect)
|
||||
return this.reconcileUpwardDomMovement(metrics)
|
||||
}
|
||||
|
||||
recordProgrammaticOffset(offset: number, atBottom: boolean) {
|
||||
this.state.lastObservedOffset = offset
|
||||
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
|
||||
}
|
||||
|
||||
private reconcileUpwardDomMovement(metrics: ScrollControllerMetrics): ScrollControllerResult {
|
||||
if (!this.isAutoFollowing()) return this.result(noFollowEffect)
|
||||
if (this.heldKey() !== null) return this.result(noFollowEffect)
|
||||
if (isAtBottom(metrics)) return this.result(noFollowEffect)
|
||||
if (metrics.offset >= this.state.lastObservedOffset - 1) return this.result(noFollowEffect)
|
||||
this.state.lastObservedOffset = metrics.offset
|
||||
this.state.lastObservedAtBottom = false
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
private result(effect: FollowEffect): ScrollControllerResult {
|
||||
return { effect, state: this.snapshot() }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,7 @@ import type {
|
|||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
YoloStateResponse,
|
||||
WorkspaceCloneRequest,
|
||||
WorkspaceCloneResponse,
|
||||
WorktreeGitCommitRequest,
|
||||
|
|
@ -30,6 +31,7 @@ import type {
|
|||
WorktreeGitMutationResponse,
|
||||
WorktreeGitPathsRequest,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
WorkspaceFileSearchResponse,
|
||||
|
|
@ -224,8 +226,8 @@ export const serverApi = {
|
|||
body: JSON.stringify(map),
|
||||
})
|
||||
},
|
||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
|
||||
return request<WorkspaceDescriptor>("/api/workspaces", {
|
||||
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceCreateResponse> {
|
||||
return request<WorkspaceCreateResponse>("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
|
@ -520,6 +522,17 @@ export const serverApi = {
|
|||
body: JSON.stringify({ ...identity, enabled }),
|
||||
})
|
||||
},
|
||||
getYoloState(instanceId: string, sessionId: string): Promise<YoloStateResponse> {
|
||||
return request<YoloStateResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
},
|
||||
toggleYolo(instanceId: string, sessionId: string): Promise<YoloStateResponse> {
|
||||
return request<YoloStateResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise<void> {
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
|
|
|
|||
25
packages/ui/src/lib/connection-status.test.ts
Normal file
25
packages/ui/src/lib/connection-status.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { deriveDisplayConnectionStatus } from "./connection-status.ts"
|
||||
|
||||
describe("deriveDisplayConnectionStatus", () => {
|
||||
it("overlays connecting while transport is down for connected instances", () => {
|
||||
assert.equal(deriveDisplayConnectionStatus("connected", "disconnected"), "connecting")
|
||||
})
|
||||
|
||||
it("restores previous connected status when transport reconnects", () => {
|
||||
assert.equal(deriveDisplayConnectionStatus("connected", "connected"), "connected")
|
||||
})
|
||||
|
||||
it("preserves disconnected instance status while transport is down", () => {
|
||||
assert.equal(deriveDisplayConnectionStatus("disconnected", "disconnected"), "disconnected")
|
||||
})
|
||||
|
||||
it("preserves error instance status while transport is down", () => {
|
||||
assert.equal(deriveDisplayConnectionStatus("error", "disconnected"), "error")
|
||||
})
|
||||
|
||||
it("does not clear legitimate instance connecting status after transport opens", () => {
|
||||
assert.equal(deriveDisplayConnectionStatus("connecting", "connected"), "connecting")
|
||||
})
|
||||
})
|
||||
19
packages/ui/src/lib/connection-status.ts
Normal file
19
packages/ui/src/lib/connection-status.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { InstanceStreamStatus } from "../../../server/src/api-types"
|
||||
import type { WorkspaceEventTransportStatus } from "./event-transport"
|
||||
|
||||
export type ConnectionStatus = InstanceStreamStatus
|
||||
|
||||
export function deriveDisplayConnectionStatus(
|
||||
instanceStatus: ConnectionStatus | null,
|
||||
workspaceTransportStatus: WorkspaceEventTransportStatus,
|
||||
): ConnectionStatus | null {
|
||||
if (instanceStatus === "disconnected" || instanceStatus === "error") {
|
||||
return instanceStatus
|
||||
}
|
||||
|
||||
if (workspaceTransportStatus !== "connected") {
|
||||
return "connecting"
|
||||
}
|
||||
|
||||
return instanceStatus
|
||||
}
|
||||
|
|
@ -15,9 +15,12 @@ export interface WorkspaceEventTransportCallbacks {
|
|||
onBatch: (events: WorkspaceEventPayload[]) => void
|
||||
onError?: () => void
|
||||
onOpen?: () => void
|
||||
onStatus?: (status: WorkspaceEventTransportStatus) => void
|
||||
onPing?: (payload: { ts?: number }) => void
|
||||
}
|
||||
|
||||
export type WorkspaceEventTransportStatus = "connecting" | "connected" | "disconnected"
|
||||
|
||||
export interface WorkspaceEventConnection {
|
||||
disconnect: () => void
|
||||
}
|
||||
|
|
@ -25,10 +28,17 @@ export interface WorkspaceEventConnection {
|
|||
async function connectBrowserWorkspaceEvents(
|
||||
callbacks: WorkspaceEventTransportCallbacks,
|
||||
): Promise<WorkspaceEventConnection> {
|
||||
const notifyDisconnected = () => {
|
||||
callbacks.onStatus?.("disconnected")
|
||||
callbacks.onError?.()
|
||||
}
|
||||
const source = serverApi.connectEvents((event) => {
|
||||
callbacks.onBatch([event])
|
||||
}, callbacks.onError, callbacks.onPing)
|
||||
source.onopen = () => callbacks.onOpen?.()
|
||||
}, notifyDisconnected, callbacks.onPing)
|
||||
source.onopen = () => {
|
||||
callbacks.onStatus?.("connected")
|
||||
callbacks.onOpen?.()
|
||||
}
|
||||
return {
|
||||
disconnect() {
|
||||
source.close()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||
import { BOTTOM_FOLLOW_EPSILON_PX, VirtualScrollController, isAutoFollowing, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
|
||||
|
||||
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
|
@ -6,7 +7,6 @@ const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "P
|
|||
interface FollowScrollOptions {
|
||||
getScrollTopSnapshot: Accessor<number>
|
||||
setScrollTopSnapshot: (next: number) => void
|
||||
sentinelMarginPx: number
|
||||
sentinelClassName: string
|
||||
intentWindowMs?: number
|
||||
intentKeys?: ReadonlySet<string>
|
||||
|
|
@ -24,16 +24,14 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const scrollController = new VirtualScrollController(true)
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||
let pointerInteractionActive = false
|
||||
let suppressNextScrollHandling = false
|
||||
|
||||
function restoreScrollPosition(forceBottom = false) {
|
||||
|
|
@ -44,8 +42,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
container.scrollTop = container.scrollHeight
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, true)
|
||||
} else {
|
||||
container.scrollTop = lastKnownScrollTop
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,17 +55,9 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
}
|
||||
|
||||
function markUserScrollIntent() {
|
||||
function markUserScrollIntent(direction: "up" | "down" | null) {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
if (pointerInteractionActive) {
|
||||
return true
|
||||
}
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
scrollController.setUserIntent(direction, now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS))
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||
|
|
@ -74,42 +66,27 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||
const handlePointerIntent = () => {
|
||||
pointerInteractionActive = true
|
||||
markUserScrollIntent()
|
||||
}
|
||||
const clearPointerIntent = () => {
|
||||
pointerInteractionActive = false
|
||||
const handlePointerIntent = (event: WheelEvent | PointerEvent | TouchEvent) => {
|
||||
markUserScrollIntent(event instanceof WheelEvent ? (event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) : null)
|
||||
}
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (intentKeys.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
const direction =
|
||||
event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home" || (event.shiftKey && (event.key === " " || event.key === "Spacebar"))
|
||||
? "up"
|
||||
: "down"
|
||||
markUserScrollIntent(direction)
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pointerup", clearPointerIntent)
|
||||
window.addEventListener("pointercancel", clearPointerIntent)
|
||||
window.addEventListener("mouseup", clearPointerIntent)
|
||||
window.addEventListener("touchend", clearPointerIntent)
|
||||
window.addEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pointerup", clearPointerIntent)
|
||||
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||
window.removeEventListener("mouseup", clearPointerIntent)
|
||||
window.removeEventListener("touchend", clearPointerIntent)
|
||||
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
pointerInteractionActive = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,18 +103,28 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
pendingAnchorScroll = null
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sentinelRect = sentinel.getBoundingClientRect()
|
||||
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||
const delta = sentinelRect.bottom - containerRect.bottom
|
||||
if (Math.abs(delta) > 1) {
|
||||
suppressNextScrollHandling = true
|
||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||
}
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
|
||||
})
|
||||
}
|
||||
|
||||
function getMetrics(container: HTMLDivElement): ScrollControllerMetrics {
|
||||
return {
|
||||
offset: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
sentinelMarginPx: BOTTOM_FOLLOW_EPSILON_PX,
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(container: HTMLDivElement) {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_EPSILON_PX
|
||||
}
|
||||
|
||||
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||
|
|
@ -147,17 +134,8 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
suppressNextScrollHandling = false
|
||||
return
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
const atBottomFromScroll = isAtBottom(container)
|
||||
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll || !atBottom) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
const result = scrollController.observeViewport(getMetrics(container), typeof performance !== "undefined" ? performance.now() : Date.now(), false)
|
||||
setAutoScroll(isAutoFollowing(result.state.mode))
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
|
|
@ -166,7 +144,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
}
|
||||
|
||||
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||
const next = element || undefined
|
||||
const next = config?.disableTracking ? undefined : element || undefined
|
||||
if (next === scrollContainerRef) {
|
||||
return
|
||||
}
|
||||
|
|
@ -185,10 +163,13 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
|
||||
const restoreAfterRender = () => {
|
||||
const container = scrollContainerRef
|
||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||
if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
if (!container) return
|
||||
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
const result = scrollController.observeViewport(getMetrics(container), now, false)
|
||||
setAutoScroll(isAutoFollowing(result.state.mode))
|
||||
const hasFreshUpwardEscape = now <= result.state.userIntentUntil && result.state.userIntentDirection === "up" && result.state.mode.type === "escaped"
|
||||
if (hasFreshUpwardEscape) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(false)
|
||||
})
|
||||
|
|
@ -219,24 +200,6 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target === sentinel) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { activeInstanceId } from "../stores/instances"
|
||||
import { selectAppTabByIndex } from "../stores/app-tabs"
|
||||
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
|
||||
import { activeSessionId, setActiveSession, getSessionFamily, activeParentSessionId } from "../stores/sessions"
|
||||
import { keyboardRegistry } from "./keyboard-registry"
|
||||
import { isMac } from "./keyboard-utils"
|
||||
|
||||
|
|
@ -48,8 +48,7 @@ export function setupTabKeyboardShortcuts(
|
|||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
|
||||
const sessions = getSessions(instanceId)
|
||||
const sessionFamily = sessions.filter((s) => s.id === parentId || s.parentId === parentId)
|
||||
const sessionFamily = getSessionFamily(instanceId, parentId)
|
||||
const allTabs = sessionFamily.map((s) => s.id).concat(["logs"])
|
||||
|
||||
if (allTabs[index]) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { createTerminalErrorNotifier } from "./desktop-events.ts"
|
||||
import {
|
||||
connectTauriWorkspaceEvents,
|
||||
createTerminalErrorNotifier,
|
||||
mapDesktopEventTransportStatus,
|
||||
} from "./desktop-events.ts"
|
||||
|
||||
describe("createTerminalErrorNotifier", () => {
|
||||
it("calls onError once for repeated terminal notifications", () => {
|
||||
|
|
@ -17,3 +21,78 @@ describe("createTerminalErrorNotifier", () => {
|
|||
assert.equal(errors, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapDesktopEventTransportStatus", () => {
|
||||
it("maps native connected state to shared connected state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("connected"), "connected")
|
||||
})
|
||||
|
||||
it("maps native connecting state to shared connecting state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("connecting"), "connecting")
|
||||
})
|
||||
|
||||
it("maps native transient failures to shared disconnected state", () => {
|
||||
assert.equal(mapDesktopEventTransportStatus("disconnected"), "disconnected")
|
||||
assert.equal(mapDesktopEventTransportStatus("error"), "disconnected")
|
||||
assert.equal(mapDesktopEventTransportStatus("unauthorized"), "disconnected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectTauriWorkspaceEvents", () => {
|
||||
it("marks the transport connected when a batch opens the native stream", async () => {
|
||||
let batchHandler: ((event: { payload: any }) => void) | undefined
|
||||
const unlistened: string[] = []
|
||||
const bridge = {
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_events_start") {
|
||||
return { started: true, generation: 1 }
|
||||
}
|
||||
if (command === "desktop_events_stop") {
|
||||
return undefined
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
},
|
||||
listen: async (eventName: string, handler: (event: { payload: any }) => void) => {
|
||||
if (eventName === "desktop:event-batch") {
|
||||
batchHandler = handler
|
||||
}
|
||||
return () => {
|
||||
unlistened.push(eventName)
|
||||
}
|
||||
},
|
||||
} as any
|
||||
|
||||
const statuses: string[] = []
|
||||
const batches: unknown[] = []
|
||||
let opens = 0
|
||||
|
||||
const connection = await connectTauriWorkspaceEvents(
|
||||
{
|
||||
onBatch: (events) => batches.push(events),
|
||||
onOpen: () => {
|
||||
opens += 1
|
||||
},
|
||||
onStatus: (status) => statuses.push(status),
|
||||
},
|
||||
{ reconnect: {} },
|
||||
bridge,
|
||||
)
|
||||
|
||||
assert.ok(batchHandler)
|
||||
batchHandler({
|
||||
payload: {
|
||||
generation: 1,
|
||||
sequence: 1,
|
||||
emittedAt: Date.now(),
|
||||
events: [{ type: "server.heartbeat" }],
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(statuses, ["connected"])
|
||||
assert.equal(opens, 1)
|
||||
assert.equal(batches.length, 1)
|
||||
|
||||
connection.disconnect()
|
||||
assert.deepEqual(unlistened, ["desktop:event-batch", "desktop:event-stream-status"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import type { WorkspaceEventPayload } from "../../../../server/src/api-types"
|
|||
import type {
|
||||
DesktopEventsStartResult,
|
||||
DesktopEventTransportStartOptions,
|
||||
DesktopEventTransportState,
|
||||
DesktopEventTransportStatusPayload,
|
||||
} from "../event-transport-contract"
|
||||
import type { WorkspaceEventConnection, WorkspaceEventTransportCallbacks } from "../event-transport"
|
||||
import type {
|
||||
WorkspaceEventConnection,
|
||||
WorkspaceEventTransportCallbacks,
|
||||
WorkspaceEventTransportStatus,
|
||||
} from "../event-transport"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("sse")
|
||||
|
|
@ -18,6 +23,16 @@ interface WorkspaceEventBatchPayload {
|
|||
events: WorkspaceEventPayload[]
|
||||
}
|
||||
|
||||
interface DesktopEventTransportBridge {
|
||||
invoke: typeof invoke
|
||||
listen: typeof listen
|
||||
}
|
||||
|
||||
const defaultDesktopEventTransportBridge: DesktopEventTransportBridge = {
|
||||
invoke,
|
||||
listen,
|
||||
}
|
||||
|
||||
export function createTerminalErrorNotifier(callbacks: Pick<WorkspaceEventTransportCallbacks, "onError">) {
|
||||
let raised = false
|
||||
return () => {
|
||||
|
|
@ -27,9 +42,18 @@ export function createTerminalErrorNotifier(callbacks: Pick<WorkspaceEventTransp
|
|||
}
|
||||
}
|
||||
|
||||
export function mapDesktopEventTransportStatus(
|
||||
state: DesktopEventTransportState,
|
||||
): WorkspaceEventTransportStatus {
|
||||
if (state === "connected") return "connected"
|
||||
if (state === "connecting") return "connecting"
|
||||
return "disconnected"
|
||||
}
|
||||
|
||||
export async function connectTauriWorkspaceEvents(
|
||||
callbacks: WorkspaceEventTransportCallbacks,
|
||||
options: DesktopEventTransportStartOptions,
|
||||
bridge: DesktopEventTransportBridge = defaultDesktopEventTransportBridge,
|
||||
): Promise<WorkspaceEventConnection> {
|
||||
let closed = false
|
||||
let opened = false
|
||||
|
|
@ -45,6 +69,7 @@ export async function connectTauriWorkspaceEvents(
|
|||
|
||||
if (!opened) {
|
||||
opened = true
|
||||
callbacks.onStatus?.("connected")
|
||||
callbacks.onOpen?.()
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +84,8 @@ export async function connectTauriWorkspaceEvents(
|
|||
const handleStatusPayload = (payload: DesktopEventTransportStatusPayload) => {
|
||||
if (!payload || !matchesGeneration(payload.generation)) return
|
||||
|
||||
callbacks.onStatus?.(mapDesktopEventTransportStatus(payload.state))
|
||||
|
||||
if (payload.state === "connected" && !opened) {
|
||||
opened = true
|
||||
callbacks.onOpen?.()
|
||||
|
|
@ -107,7 +134,7 @@ export async function connectTauriWorkspaceEvents(
|
|||
}
|
||||
}
|
||||
|
||||
const unlistenBatch = await listen<WorkspaceEventBatchPayload>("desktop:event-batch", (event) => {
|
||||
const unlistenBatch = await bridge.listen<WorkspaceEventBatchPayload>("desktop:event-batch", (event) => {
|
||||
if (closed) return
|
||||
const payload = event.payload
|
||||
if (!payload) return
|
||||
|
|
@ -118,7 +145,7 @@ export async function connectTauriWorkspaceEvents(
|
|||
handleBatchPayload(payload)
|
||||
})
|
||||
|
||||
const unlistenStatus = await listen<DesktopEventTransportStatusPayload>("desktop:event-stream-status", (event) => {
|
||||
const unlistenStatus = await bridge.listen<DesktopEventTransportStatusPayload>("desktop:event-stream-status", (event) => {
|
||||
if (closed) return
|
||||
const payload = event.payload
|
||||
if (!payload) return
|
||||
|
|
@ -130,7 +157,7 @@ export async function connectTauriWorkspaceEvents(
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await invoke<DesktopEventsStartResult>("desktop_events_start", { request: options })
|
||||
const result = await bridge.invoke<DesktopEventsStartResult>("desktop_events_start", { request: options })
|
||||
if (!result?.started) {
|
||||
throw new Error(result?.reason ?? "desktop event transport unavailable")
|
||||
}
|
||||
|
|
@ -151,7 +178,7 @@ export async function connectTauriWorkspaceEvents(
|
|||
closed = true
|
||||
unlistenBatch()
|
||||
unlistenStatus()
|
||||
void invoke("desktop_events_stop").catch((error) => {
|
||||
void bridge.invoke("desktop_events_stop").catch((error) => {
|
||||
log.warn("Failed to stop native desktop event transport", error)
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { batch as solidBatch } from "solid-js"
|
|||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { getClientIdentity } from "./client-identity"
|
||||
import { connectWorkspaceEvents, type WorkspaceEventConnection } from "./event-transport"
|
||||
import {
|
||||
connectWorkspaceEvents,
|
||||
type WorkspaceEventConnection,
|
||||
type WorkspaceEventTransportStatus,
|
||||
} from "./event-transport"
|
||||
import { getLogger } from "./logger"
|
||||
import { retryWithBackoff, isRetryableError } from "./retry-utils"
|
||||
|
||||
|
|
@ -21,6 +25,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
|
|||
class ServerEvents {
|
||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||
private openHandlers = new Set<() => void>()
|
||||
private statusHandlers = new Set<(status: WorkspaceEventTransportStatus) => void>()
|
||||
private connection: WorkspaceEventConnection | null = null
|
||||
private connectGeneration = 0
|
||||
private retryDelay = RETRY_BASE_DELAY
|
||||
|
|
@ -50,6 +55,12 @@ class ServerEvents {
|
|||
}
|
||||
this.scheduleReconnect()
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (generation !== this.connectGeneration) {
|
||||
return
|
||||
}
|
||||
this.emitTransportStatus(status)
|
||||
},
|
||||
onOpen: () => {
|
||||
if (generation !== this.connectGeneration) {
|
||||
return
|
||||
|
|
@ -105,6 +116,8 @@ class ServerEvents {
|
|||
this.connection = null
|
||||
}
|
||||
|
||||
this.emitTransportStatus("disconnected")
|
||||
|
||||
logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay })
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null
|
||||
|
|
@ -140,6 +153,10 @@ class ServerEvents {
|
|||
})
|
||||
}
|
||||
|
||||
private emitTransportStatus(status: WorkspaceEventTransportStatus) {
|
||||
this.statusHandlers.forEach((handler) => handler(status))
|
||||
}
|
||||
|
||||
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, new Set())
|
||||
|
|
@ -154,6 +171,11 @@ class ServerEvents {
|
|||
return () => this.openHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onTransportStatus(handler: (status: WorkspaceEventTransportStatus) => void): () => void {
|
||||
this.statusHandlers.add(handler)
|
||||
return () => this.statusHandlers.delete(handler)
|
||||
}
|
||||
|
||||
restart(reason = "manual restart"): void {
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
this.clearReconnectTimer()
|
||||
|
|
|
|||
|
|
@ -24,13 +24,14 @@ import type {
|
|||
} from "@opencode-ai/sdk/v2"
|
||||
import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission"
|
||||
import { serverEvents } from "./server-events"
|
||||
import type { WorkspaceEventTransportStatus } from "./event-transport"
|
||||
import type {
|
||||
BackgroundProcess,
|
||||
InstanceStreamEvent,
|
||||
InstanceStreamStatus,
|
||||
WorkspaceEventPayload,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
import { deriveDisplayConnectionStatus, type ConnectionStatus } from "./connection-status"
|
||||
|
||||
const log = getLogger("sse")
|
||||
|
||||
|
|
@ -98,12 +99,13 @@ type SSEEvent =
|
|||
| ServerInstanceDisposedEvent
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
|
||||
const [transportStatus, setTransportStatus] = createSignal<WorkspaceEventTransportStatus>("connecting")
|
||||
|
||||
class SSEManager {
|
||||
constructor() {
|
||||
log.info("sseManager initialized: listening for SSE disconnect and reconnect")
|
||||
|
||||
serverEvents.on("instance.eventStatus", (event) => {
|
||||
const payload = event as InstanceStatusPayload
|
||||
this.updateConnectionStatus(payload.instanceId, payload.status)
|
||||
|
|
@ -121,6 +123,11 @@ class SSEManager {
|
|||
this.updateConnectionStatus(payload.instanceId, "connected")
|
||||
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
|
||||
})
|
||||
|
||||
serverEvents.onTransportStatus((status) => {
|
||||
log.info("SSE transport status changed", { status })
|
||||
setTransportStatus(status)
|
||||
})
|
||||
}
|
||||
|
||||
seedStatus(instanceId: string, status: ConnectionStatus) {
|
||||
|
|
@ -246,7 +253,7 @@ class SSEManager {
|
|||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): ConnectionStatus | null {
|
||||
return connectionStatus().get(instanceId) ?? null
|
||||
return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus())
|
||||
}
|
||||
|
||||
getStatuses() {
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ import {
|
|||
pruneRepliedPermissions,
|
||||
} from "./permission-replies"
|
||||
import {
|
||||
clearAutoAcceptPermission,
|
||||
drainAutoAcceptPermissions,
|
||||
clearPermissionAutoAcceptForInstance,
|
||||
isPermissionAutoAcceptEnabled,
|
||||
resolvePermissionAutoAcceptFamilyRoot,
|
||||
setPermissionAutoAcceptEnabled,
|
||||
setPermissionAutoAcceptFamilyRootResolver,
|
||||
togglePermissionAutoAccept,
|
||||
} from "./permission-auto-accept"
|
||||
|
|
@ -65,6 +65,31 @@ setPermissionAutoAcceptFamilyRootResolver((instanceId, sessionId) => {
|
|||
return resolvePermissionAutoAcceptFamilyRoot(sessionId, (id) => instanceSessions.get(id))
|
||||
})
|
||||
|
||||
// Server is authoritative for Yolo state; mirror toggles (incl. from other
|
||||
// clients) arriving over the CodeNomad server event stream into the local
|
||||
// projection so the badge/switch stay in sync.
|
||||
serverEvents.on("yolo.stateChanged", (event) => {
|
||||
if (event.type !== "yolo.stateChanged") return
|
||||
const { instanceId, sessionId, enabled } = event
|
||||
if (typeof instanceId !== "string" || typeof sessionId !== "string" || typeof enabled !== "boolean") return
|
||||
log.info(`[SSE] Yolo state changed: ${instanceId}:${sessionId} -> ${enabled}`)
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, enabled)
|
||||
})
|
||||
|
||||
// When the server auto-accepts a permission, clean up the UI queue immediately
|
||||
// instead of waiting for the OpenCode permission.replied SSE event (which may
|
||||
// be delayed or missed on a flaky connection). This preserves the #424
|
||||
// invariant: auto-accept cleanup happens at the queue level, not tied to
|
||||
// external event timing.
|
||||
serverEvents.on("yolo.autoAccepted", (event) => {
|
||||
if (event.type !== "yolo.autoAccepted") return
|
||||
const { instanceId, permissionId } = event
|
||||
if (typeof instanceId !== "string" || typeof permissionId !== "string") return
|
||||
markPermissionReplied(instanceId, permissionId)
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
removePermissionV2(instanceId, permissionId)
|
||||
})
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
|
|
@ -187,7 +212,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor, projectN
|
|||
return {
|
||||
id: descriptor.id,
|
||||
folder: descriptor.path,
|
||||
projectName: descriptor.name ?? projectName ?? existing?.projectName,
|
||||
projectName: projectName ?? existing?.projectName ?? descriptor.name,
|
||||
port: descriptor.port ?? existing?.port ?? 0,
|
||||
pid: descriptor.pid ?? existing?.pid ?? 0,
|
||||
proxyPath: descriptor.proxyPath,
|
||||
|
|
@ -326,7 +351,6 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
|
|||
const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission
|
||||
upsertPermissionV2(instanceId, queuedPermission)
|
||||
}
|
||||
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
|
||||
} catch (error) {
|
||||
log.warn("Failed to sync pending permissions", { instanceId, error })
|
||||
}
|
||||
|
|
@ -519,6 +543,8 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
|||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
showWorkspaceLaunchError(event.workspace)
|
||||
clearPermissionAutoAcceptForInstance(event.workspace.id)
|
||||
clearSyncedYoloSessionsForInstance(event.workspace.id)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
|
|
@ -657,6 +683,8 @@ function removeInstance(id: string) {
|
|||
clearRepliedPermissions(id)
|
||||
clearQuestionQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
clearPermissionAutoAcceptForInstance(id)
|
||||
clearSyncedYoloSessionsForInstance(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
|
|
@ -669,12 +697,20 @@ function removeInstance(id: string) {
|
|||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string, projectName?: string): Promise<string> {
|
||||
async function createInstance(
|
||||
folder: string,
|
||||
_binaryPath?: string,
|
||||
projectName?: string,
|
||||
options?: { forceNew?: boolean },
|
||||
): Promise<{ instanceId: string; reused: boolean }> {
|
||||
try {
|
||||
const workspace = await serverApi.createWorkspace({ path: folder, name: projectName })
|
||||
upsertWorkspace(workspace, projectName)
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
const workspace = await serverApi.createWorkspace({ path: folder, name: projectName, forceNew: options?.forceNew })
|
||||
const reused = workspace.reused === true
|
||||
upsertWorkspace(workspace, reused ? undefined : projectName)
|
||||
if (!reused) {
|
||||
setActiveInstanceId(workspace.id)
|
||||
}
|
||||
return { instanceId: workspace.id, reused }
|
||||
} catch (error) {
|
||||
log.error("Failed to create workspace", error)
|
||||
throw error
|
||||
|
|
@ -1001,7 +1037,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest,
|
|||
|
||||
}
|
||||
|
||||
drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission)
|
||||
return queuedPermission
|
||||
}
|
||||
|
||||
|
|
@ -1037,7 +1072,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||
if (removed) {
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
if (removedSessionId) {
|
||||
clearAutoAcceptPermission(instanceId, removedSessionId, permissionId)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
|
|
@ -1045,23 +1079,61 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
|||
}
|
||||
|
||||
function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void {
|
||||
const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
|
||||
const wasEnabled = isPermissionAutoAcceptEnabled(instanceId, sessionId)
|
||||
togglePermissionAutoAccept(instanceId, sessionId)
|
||||
if (!willEnable) return
|
||||
drainAutoAcceptPermissionsForInstance(instanceId)
|
||||
void serverApi
|
||||
.toggleYolo(instanceId, sessionId)
|
||||
.then((state) => {
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled)
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to toggle Yolo on server", { instanceId, sessionId, error })
|
||||
// revert to the pre-toggle state (not a naive flip, which can be wrong
|
||||
// if an SSE yolo.stateChanged arrived between toggle and catch)
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, wasEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
function drainAutoAcceptPermissionsForInstance(instanceId: string): void {
|
||||
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
|
||||
/**
|
||||
* Sessions whose Yolo state has been backfilled from the server. The server is
|
||||
* authoritative but only pushes changes (`yolo.stateChanged`); a freshly
|
||||
* connected client must fetch the effective state for a session so the badge
|
||||
* matches reality from the start. De-duped per session and reset on SSE
|
||||
* reconnect so state re-syncs after a server restart.
|
||||
*/
|
||||
const syncedYoloSessions = new Set<string>()
|
||||
|
||||
export function ensureYoloStateSynced(instanceId: string, sessionId: string): void {
|
||||
if (!instanceId || !sessionId || sessionId === "info") return
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
if (syncedYoloSessions.has(key)) return
|
||||
syncedYoloSessions.add(key)
|
||||
void serverApi
|
||||
.getYoloState(instanceId, sessionId)
|
||||
.then((state) => {
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled)
|
||||
})
|
||||
.catch((error) => {
|
||||
// allow retry on next activation (e.g. instance not ready yet)
|
||||
syncedYoloSessions.delete(key)
|
||||
log.warn("Failed to sync Yolo state", { instanceId, sessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
serverEvents.onOpen(() => {
|
||||
syncedYoloSessions.clear()
|
||||
})
|
||||
|
||||
function clearSyncedYoloSessionsForInstance(instanceId: string): void {
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(syncedYoloSessions)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
syncedYoloSessions.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
for (const permission of getPermissionQueue(instanceId)) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (sessionId) {
|
||||
clearAutoAcceptPermission(instanceId, sessionId, permission.id)
|
||||
}
|
||||
}
|
||||
for (const permission of getPermissionQueue(instanceId)) {
|
||||
permissionEnqueuedAt.delete(permission.id)
|
||||
}
|
||||
|
|
@ -1391,7 +1463,6 @@ export {
|
|||
markPermissionReplied,
|
||||
hasRepliedPermission,
|
||||
togglePermissionAutoAcceptForSession,
|
||||
drainAutoAcceptPermissionsForInstance,
|
||||
clearPermissionQueue,
|
||||
sendPermissionResponse,
|
||||
setActivePermissionIdForInstance,
|
||||
|
|
|
|||
|
|
@ -40,4 +40,5 @@ describe("message-v2 permission state", () => {
|
|||
assert.equal(store.state.permissions.active?.permission.id, "permission-2")
|
||||
assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { mergePermissionRequest } from "../../types/permission"
|
||||
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe"
|
||||
import type {
|
||||
InstanceMessageState,
|
||||
LatestTodoSnapshot,
|
||||
|
|
@ -958,6 +959,23 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
const entry = mergePermissionEntry(input)
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
|
||||
const existing = state.permissions.queue.find((item) => item.permission.id === entry.permission.id)
|
||||
const existingAtLocation = state.permissions.byMessage[messageKey]?.[partKey]
|
||||
const expectedActiveId = state.permissions.queue[0]?.permission.id
|
||||
if (shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: existingAtLocation?.permission.id,
|
||||
expectedActiveId,
|
||||
activeId: state.permissions.active?.permission.id,
|
||||
incomingId: entry.permission.id,
|
||||
incomingMessageId: entry.messageId,
|
||||
incomingPartId: entry.partId,
|
||||
incomingEnqueuedAt: entry.enqueuedAt,
|
||||
existingValue: existing?.permission,
|
||||
incomingValue: entry.permission,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(
|
||||
"permissions",
|
||||
|
|
@ -1019,13 +1037,47 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
return { entry, active }
|
||||
}
|
||||
|
||||
function upsertQuestion(entry: QuestionEntry) {
|
||||
function mergeQuestionEntry(entry: QuestionEntry): QuestionEntry {
|
||||
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
|
||||
return mergePendingRequestEntry(entry, existing)
|
||||
}
|
||||
|
||||
function upsertQuestion(input: QuestionEntry) {
|
||||
const entry = mergeQuestionEntry(input)
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
|
||||
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
|
||||
const existingAtLocation = state.questions.byMessage[messageKey]?.[partKey]
|
||||
const expectedActiveId = state.questions.queue[0]?.request.id
|
||||
if (shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: existingAtLocation?.request.id,
|
||||
expectedActiveId,
|
||||
activeId: state.questions.active?.request.id,
|
||||
incomingId: entry.request.id,
|
||||
incomingMessageId: entry.messageId,
|
||||
incomingPartId: entry.partId,
|
||||
incomingEnqueuedAt: entry.enqueuedAt,
|
||||
existingValue: existing?.request,
|
||||
incomingValue: entry.request,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(
|
||||
"questions",
|
||||
produce((draft) => {
|
||||
Object.keys(draft.byMessage).forEach((existingMessageKey) => {
|
||||
const partEntries = draft.byMessage[existingMessageKey]
|
||||
Object.keys(partEntries).forEach((existingPartKey) => {
|
||||
if (partEntries[existingPartKey].request.id === entry.request.id) {
|
||||
delete partEntries[existingPartKey]
|
||||
}
|
||||
})
|
||||
if (Object.keys(partEntries).length === 0) {
|
||||
delete draft.byMessage[existingMessageKey]
|
||||
}
|
||||
})
|
||||
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||
draft.byMessage[messageKey][partKey] = entry
|
||||
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe.ts"
|
||||
|
||||
describe("pending request dedupe", () => {
|
||||
it("skips unchanged polling updates at the same attachment location", () => {
|
||||
const existing = {
|
||||
messageId: "message-1",
|
||||
partId: "part-1",
|
||||
enqueuedAt: 1_000,
|
||||
}
|
||||
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
|
||||
|
||||
assert.equal(shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: "question-1",
|
||||
expectedActiveId: "question-1",
|
||||
activeId: "question-1",
|
||||
incomingId: "question-1",
|
||||
incomingMessageId: "message-1",
|
||||
incomingPartId: "part-1",
|
||||
incomingEnqueuedAt: 1_000,
|
||||
existingValue: request,
|
||||
incomingValue: { ...request, questions: [...request.questions] },
|
||||
}), true)
|
||||
})
|
||||
|
||||
it("does not skip when polling resolves a request from global state to a tool part", () => {
|
||||
const existing = {
|
||||
enqueuedAt: 1_000,
|
||||
}
|
||||
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
|
||||
|
||||
assert.equal(shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: undefined,
|
||||
expectedActiveId: "question-1",
|
||||
activeId: "question-1",
|
||||
incomingId: "question-1",
|
||||
incomingMessageId: "message-1",
|
||||
incomingPartId: "part-1",
|
||||
incomingEnqueuedAt: 1_000,
|
||||
existingValue: request,
|
||||
incomingValue: request,
|
||||
}), false)
|
||||
})
|
||||
|
||||
it("keeps the earliest queue time while preserving resolved attachment ids", () => {
|
||||
const merged = mergePendingRequestEntry(
|
||||
{ messageId: "message-1", partId: "part-1", enqueuedAt: 3_000 },
|
||||
{ enqueuedAt: 1_000 },
|
||||
)
|
||||
|
||||
assert.deepEqual(merged, { messageId: "message-1", partId: "part-1", enqueuedAt: 1_000 })
|
||||
})
|
||||
})
|
||||
50
packages/ui/src/stores/message-v2/pending-request-dedupe.ts
Normal file
50
packages/ui/src/stores/message-v2/pending-request-dedupe.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface PendingRequestEntryLike {
|
||||
messageId?: string
|
||||
partId?: string
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
export interface PendingRequestSkipInput {
|
||||
existing: PendingRequestEntryLike | undefined
|
||||
existingAtLocationId: string | undefined
|
||||
expectedActiveId: string | undefined
|
||||
activeId: string | undefined
|
||||
incomingId: string
|
||||
incomingMessageId: string | undefined
|
||||
incomingPartId: string | undefined
|
||||
incomingEnqueuedAt: number
|
||||
existingValue: unknown
|
||||
incomingValue: unknown
|
||||
}
|
||||
|
||||
export function areStructuredValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePendingRequestEntry<T extends PendingRequestEntryLike>(entry: T, existing: T | undefined): T {
|
||||
if (!existing) return entry
|
||||
return {
|
||||
...entry,
|
||||
messageId: entry.messageId ?? existing.messageId,
|
||||
partId: entry.partId ?? existing.partId,
|
||||
enqueuedAt: Math.min(existing.enqueuedAt, entry.enqueuedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSkipPendingRequestUpsert(input: PendingRequestSkipInput): boolean {
|
||||
const existing = input.existing
|
||||
return Boolean(
|
||||
existing &&
|
||||
input.existingAtLocationId === input.incomingId &&
|
||||
input.activeId === input.expectedActiveId &&
|
||||
existing.messageId === input.incomingMessageId &&
|
||||
existing.partId === input.incomingPartId &&
|
||||
existing.enqueuedAt === input.incomingEnqueuedAt &&
|
||||
areStructuredValuesEqual(input.existingValue, input.incomingValue),
|
||||
)
|
||||
}
|
||||
|
|
@ -82,9 +82,7 @@ export interface ScrollSnapshot {
|
|||
anchorKey?: string
|
||||
anchorOffset?: number
|
||||
atBottom: boolean
|
||||
followModeType?: "following" | "escaped" | "holding"
|
||||
heldKey?: string
|
||||
holdAnchorSuspended?: boolean
|
||||
followModeType?: "following" | "escaped"
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,28 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import type { PermissionReply, PermissionRequest } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
|
||||
/**
|
||||
* UI-side mirror of the server-owned Yolo (permission auto-accept) state.
|
||||
*
|
||||
* The server is authoritative: it holds the toggle state, resolves
|
||||
* family-root inheritance, and performs the actual `"once"` replies. This
|
||||
* module only keeps a runtime (NON-persisted) projection so the UI can render
|
||||
* the badge / switch synchronously.
|
||||
*
|
||||
* State is populated from:
|
||||
* - local toggles (optimistic, then confirmed via REST)
|
||||
* - `yolo.stateChanged` SSE events (wired in the app bootstrap, see
|
||||
* `stores/instances.ts`, so toggles from other clients reflect)
|
||||
*
|
||||
* `resolvePermissionAutoAcceptFamilyRoot` is retained as a display aid so the
|
||||
* badge correctly lights up for child/sub-sessions of an enabled family root,
|
||||
* preserving the previous inheritance UX exactly. The server performs the same
|
||||
* resolution independently when deciding whether to auto-reply.
|
||||
*
|
||||
* NOTE: intentionally pure — no SSE/REST side effects at module load, so it
|
||||
* stays unit-testable.
|
||||
*/
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise<void>
|
||||
type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean
|
||||
type PermissionAutoAcceptSession = {
|
||||
export type PermissionAutoAcceptSession = {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
revert?: unknown
|
||||
|
|
@ -45,112 +58,44 @@ function makeKey(instanceId: string, sessionId: string) {
|
|||
return `${instanceId}:${resolveFamilyRoot(instanceId, sessionId)}`
|
||||
}
|
||||
|
||||
function readInitialState() {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return new Map<string, boolean>()
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return new Map<string, boolean>()
|
||||
const parsed = JSON.parse(raw) as Record<string, boolean>
|
||||
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
|
||||
} catch {
|
||||
return new Map<string, boolean>()
|
||||
}
|
||||
}
|
||||
|
||||
function persist(next: Map<string, boolean>) {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next)))
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
|
||||
|
||||
const inFlight = new Set<string>()
|
||||
const [autoAcceptState, setAutoAcceptState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) {
|
||||
return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false
|
||||
}
|
||||
|
||||
export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) {
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
setAutoAcceptState((prev) => {
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
if (prev.get(key) === enabled) return prev
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
next.set(key, true)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
persist(next)
|
||||
return next
|
||||
})
|
||||
if (!enabled) {
|
||||
clearAutoAcceptSession(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
|
||||
const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
|
||||
setPermissionAutoAcceptEnabled(instanceId, sessionId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function makeRequestKey(instanceId: string, sessionId: string, requestId: string) {
|
||||
return `${makeKey(instanceId, sessionId)}:${requestId}`
|
||||
}
|
||||
|
||||
export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) {
|
||||
const requestKey = makeRequestKey(instanceId, sessionId, requestId)
|
||||
inFlight.delete(requestKey)
|
||||
}
|
||||
|
||||
export function clearAutoAcceptSession(instanceId: string, sessionId: string) {
|
||||
const prefix = `${makeKey(instanceId, sessionId)}:`
|
||||
for (const requestKey of Array.from(inFlight)) {
|
||||
if (requestKey.startsWith(prefix)) {
|
||||
inFlight.delete(requestKey)
|
||||
/** Remove all Yolo state entries for an instance (workspace stop / removal). */
|
||||
export function clearPermissionAutoAcceptForInstance(instanceId: string) {
|
||||
setAutoAcceptState((prev) => {
|
||||
const prefix = `${instanceId}:`
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drainAutoAcceptPermission(
|
||||
instanceId: string,
|
||||
permission: PermissionRequest,
|
||||
responder: AutoAcceptResponder,
|
||||
isPending: PendingPermissionChecker,
|
||||
) {
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
if (!sessionId || !permission?.id) return
|
||||
if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return
|
||||
if (!isPending(instanceId, permission.id)) return
|
||||
|
||||
const requestKey = makeRequestKey(instanceId, sessionId, permission.id)
|
||||
if (inFlight.has(requestKey)) return
|
||||
|
||||
inFlight.add(requestKey)
|
||||
|
||||
void responder(instanceId, sessionId, permission.id, "once")
|
||||
.catch((error) => {
|
||||
log.error("Failed to auto-accept permission", error)
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(requestKey)
|
||||
})
|
||||
}
|
||||
|
||||
export function drainAutoAcceptPermissions(
|
||||
instanceId: string,
|
||||
permissions: PermissionRequest[],
|
||||
responder: AutoAcceptResponder,
|
||||
isPending: PendingPermissionChecker,
|
||||
) {
|
||||
for (const permission of permissions) {
|
||||
drainAutoAcceptPermission(instanceId, permission, responder, isPending)
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,13 +400,16 @@ function getModelKey(model: { providerId: string; modelId: string }): string {
|
|||
return `${model.providerId}/${model.modelId}`
|
||||
}
|
||||
|
||||
function buildRecentFolderList(folderPath: string, source: RecentFolder[]): RecentFolder[] {
|
||||
const existing = source.find((f) => f.path === folderPath)
|
||||
const folders = source.filter((f) => f.path !== folderPath)
|
||||
function buildRecentFolderList(folderPath: string, source: RecentFolder[], aliasPath?: string): RecentFolder[] {
|
||||
const matchingPaths = new Set([folderPath, aliasPath].filter((value): value is string => Boolean(value)))
|
||||
const aliasEntry = aliasPath ? source.find((folder) => folder.path === aliasPath) : undefined
|
||||
const canonicalEntry = source.find((folder) => folder.path === folderPath)
|
||||
const projectName = aliasEntry?.projectName ?? canonicalEntry?.projectName
|
||||
const folders = source.filter((folder) => !matchingPaths.has(folder.path))
|
||||
folders.unshift({
|
||||
path: folderPath,
|
||||
lastAccessed: Date.now(),
|
||||
...(existing?.projectName ? { projectName: existing.projectName } : {}),
|
||||
...(projectName ? { projectName } : {}),
|
||||
})
|
||||
return folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
}
|
||||
|
|
@ -711,9 +714,9 @@ function removeRemoteServerProfile(id: string): void {
|
|||
void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error))
|
||||
}
|
||||
|
||||
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
|
||||
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string, aliasPath?: string): void {
|
||||
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary
|
||||
const nextFolders = buildRecentFolderList(folderPath, recentFolders())
|
||||
const nextFolders = buildRecentFolderList(folderPath, recentFolders(), aliasPath)
|
||||
const nextBinaries = buildBinaryList(targetBinary, undefined, opencodeBinaries())
|
||||
|
||||
void patchStateOwner("ui", { recentFolders: nextFolders, opencodeBinaries: nextBinaries }).catch((error) =>
|
||||
|
|
|
|||
|
|
@ -53,10 +53,17 @@ import { clearCacheForSession } from "../lib/global-cache"
|
|||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees"
|
||||
import {
|
||||
getWorktreeSlugForSession,
|
||||
getWorktrees,
|
||||
migrateLegacyWorktreeMapToSessionMetadata,
|
||||
pruneStaleLegacyWorktreeMapEntries,
|
||||
removeLegacyParentSessionMapping,
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "./worktrees"
|
||||
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
|
||||
import { hydrateSessionMetadataWithClient } from "./session-metadata"
|
||||
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options"
|
||||
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
|
|
@ -142,7 +149,11 @@ async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions
|
|||
const client = getRootClient(instanceId)
|
||||
const listOptions = buildProjectSessionListOptions(options)
|
||||
const data = await requestData<SessionListResponse>(client.session.list(listOptions), "session.list")
|
||||
return { data }
|
||||
const allowedDirectories = [options.directory, ...getWorktrees(instanceId).map((worktree) => worktree.directory)]
|
||||
|
||||
return {
|
||||
data: filterProjectScopedSessions(data, allowedDirectories),
|
||||
}
|
||||
}
|
||||
|
||||
function getV2SessionItems(response: ProjectSessionListResponse): SDKSession[] {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ import {
|
|||
hasRepliedPermission,
|
||||
addQuestionToQueue,
|
||||
removeQuestionFromQueue,
|
||||
drainAutoAcceptPermissionsForInstance,
|
||||
} from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import {
|
||||
|
|
@ -62,7 +61,7 @@ import {
|
|||
type SessionRetryState,
|
||||
type SessionStatus,
|
||||
} from "../types/session"
|
||||
import { ensureSessionParentExpanded, prependSessionListId, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||
import { ensureSessionAncestorsExpanded, prependSessionListId, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
|
|
@ -166,7 +165,7 @@ interface TuiToastEvent {
|
|||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
|
||||
let parentToExpand: string | null = null
|
||||
let expandAncestors = false
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const current = session.status ?? "idle"
|
||||
|
|
@ -184,13 +183,11 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
|
|||
// Auto-expand the parent thread when a child session starts working.
|
||||
// Users can still collapse it; we only expand on the transition.
|
||||
if (session.parentId && status === "working" && current !== "working") {
|
||||
parentToExpand = session.parentId
|
||||
expandAncestors = true
|
||||
}
|
||||
})
|
||||
|
||||
if (parentToExpand) {
|
||||
ensureSessionParentExpanded(instanceId, parentToExpand)
|
||||
}
|
||||
if (expandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
|
||||
|
|
@ -234,8 +231,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
|||
fetched.retry = fetchedRetry
|
||||
|
||||
let updatedInstanceSessions: Map<string, Session> | undefined
|
||||
let shouldExpandParent: string | null = null
|
||||
let shouldDrainAutoAcceptPermissions = false
|
||||
let shouldExpandAncestors = false
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
|
|
@ -258,23 +254,16 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
|
|||
instanceSessions.set(sessionId, merged)
|
||||
next.set(instanceId, instanceSessions)
|
||||
updatedInstanceSessions = instanceSessions
|
||||
shouldDrainAutoAcceptPermissions = Boolean(merged.parentId)
|
||||
|
||||
if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") {
|
||||
shouldExpandParent = merged.parentId
|
||||
shouldExpandAncestors = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
|
||||
if (shouldDrainAutoAcceptPermissions) {
|
||||
drainAutoAcceptPermissionsForInstance(instanceId)
|
||||
}
|
||||
|
||||
if (shouldExpandParent) {
|
||||
ensureSessionParentExpanded(instanceId, shouldExpandParent)
|
||||
}
|
||||
if (shouldExpandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
|
||||
|
||||
return fetched
|
||||
} catch (error) {
|
||||
|
|
@ -542,9 +531,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
if (newSession.parentId) {
|
||||
drainAutoAcceptPermissionsForInstance(instanceId)
|
||||
} else {
|
||||
if (!newSession.parentId) {
|
||||
prependSessionListId(instanceId, newSession.id)
|
||||
}
|
||||
|
||||
|
|
@ -585,9 +572,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
|||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
if (updatedSession.parentId) {
|
||||
drainAutoAcceptPermissionsForInstance(instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ export type ProjectSessionListOptions = ProjectSessionListInput & {
|
|||
scope: "project"
|
||||
}
|
||||
|
||||
type SessionDirectorySource = {
|
||||
directory?: string | null
|
||||
}
|
||||
|
||||
function normalizeSessionDirectory(directory: string | null | undefined): string {
|
||||
const trimmed = directory?.trim()
|
||||
if (!trimmed) return ""
|
||||
|
||||
const slashNormalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
const comparable = slashNormalized || "/"
|
||||
const isWindowsPath = /^[A-Za-z]:\//.test(comparable) || comparable.startsWith("//") || trimmed.includes("\\")
|
||||
|
||||
return isWindowsPath ? comparable.toLowerCase() : comparable
|
||||
}
|
||||
|
||||
export function buildProjectSessionListOptions(options: ProjectSessionListInput): ProjectSessionListOptions {
|
||||
return {
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
|
|
@ -18,3 +33,16 @@ export function buildProjectSessionListOptions(options: ProjectSessionListInput)
|
|||
scope: "project",
|
||||
}
|
||||
}
|
||||
|
||||
export function filterProjectScopedSessions<T extends SessionDirectorySource>(
|
||||
sessions: T[],
|
||||
allowedDirectories: Array<string | null | undefined>,
|
||||
): T[] {
|
||||
const allowed = new Set(allowedDirectories.map(normalizeSessionDirectory).filter(Boolean))
|
||||
if (allowed.size === 0) return sessions
|
||||
|
||||
return sessions.filter((session) => {
|
||||
const directory = normalizeSessionDirectory(session.directory)
|
||||
return !directory || allowed.has(directory)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|||
import { describe, it } from "node:test"
|
||||
|
||||
import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts"
|
||||
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options.ts"
|
||||
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options.ts"
|
||||
|
||||
describe("project session list loading", () => {
|
||||
it("builds a one-shot project-scoped request without pagination params", () => {
|
||||
|
|
@ -18,6 +18,35 @@ describe("project session list loading", () => {
|
|||
assert.equal("cursor" in options, false)
|
||||
})
|
||||
|
||||
it("filters project-scoped results to the root and known worktree directories", () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: "/repo" },
|
||||
{ id: "worktree", directory: "/repo/.codenomad/worktrees/feature" },
|
||||
{ id: "sibling", directory: "/other" },
|
||||
{ id: "unknown" },
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
filterProjectScopedSessions(sessions, ["/repo", "/repo/.codenomad/worktrees/feature"]).map((session) => session.id),
|
||||
["root", "worktree", "unknown"],
|
||||
)
|
||||
})
|
||||
|
||||
it("normalizes Windows paths when filtering project-scoped results", () => {
|
||||
const sessions = [
|
||||
{ id: "root", directory: String.raw`C:\Repo` },
|
||||
{ id: "worktree", directory: "c:/repo/.codenomad/worktrees/feature/" },
|
||||
{ id: "other", directory: String.raw`C:\Other` },
|
||||
]
|
||||
|
||||
assert.deepEqual(
|
||||
filterProjectScopedSessions(sessions, ["c:/repo/", String.raw`C:\Repo\.codenomad\worktrees\feature`]).map(
|
||||
(session) => session.id,
|
||||
),
|
||||
["root", "worktree"],
|
||||
)
|
||||
})
|
||||
|
||||
it("marks the loaded session list complete because the API does not paginate", () => {
|
||||
const state = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], false, true)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type
|
|||
import { deleteSession, loadMessages } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { instances } from "./instances"
|
||||
import { instances, ensureYoloStateSynced } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
|
@ -13,6 +13,16 @@ import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
|
|||
import { tGlobal } from "../lib/i18n"
|
||||
import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals"
|
||||
import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model"
|
||||
import {
|
||||
buildSessionThreadsFromMap,
|
||||
collectVisibleSessionIds,
|
||||
getDescendantSessionsFromMap,
|
||||
getSessionAncestorIdsFromMap,
|
||||
getSessionRootFromMap,
|
||||
type SessionThread,
|
||||
} from "./session-tree"
|
||||
|
||||
export type { SessionThread } from "./session-tree"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
|
|
@ -28,12 +38,6 @@ export interface SessionInfo {
|
|||
contextAvailableTokens: number | null
|
||||
}
|
||||
|
||||
export type SessionThread = {
|
||||
parent: Session
|
||||
children: Session[]
|
||||
latestUpdated: number
|
||||
}
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
|
|
@ -53,7 +57,8 @@ const [messageLoadErrors, setMessageLoadErrors] = createSignal<Map<string, Map<s
|
|||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal<Map<string, Map<string, ThreadTotals>>>(new Map())
|
||||
|
||||
const [expandedSessionParents, setExpandedSessionParents] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
// Track expansion state for ANY session that has children (not just top-level parents)
|
||||
const [expandedSessions, setExpandedSessions] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
|
||||
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
|
||||
|
||||
|
|
@ -315,7 +320,6 @@ messageStoreBus.onSessionCleared((instanceId, sessionId) => {
|
|||
})
|
||||
|
||||
function getDraftKey(instanceId: string, sessionId: string): string {
|
||||
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +458,8 @@ function markViewedSessionIdleSeen(
|
|||
const idsToClear = new Set<string>([sessionId])
|
||||
if (viewedSession.parentId === null && !keepUnseenSubagentIdleStatus) {
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (session.parentId === sessionId) idsToClear.add(session.id)
|
||||
if (session.id === sessionId) continue
|
||||
if (getSessionRootFromMap(instanceSessions, session.id)?.id === sessionId) idsToClear.add(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -483,6 +488,9 @@ function setActiveSession(instanceId: string, sessionId: string): void {
|
|||
next.set(instanceId, sessionId)
|
||||
return next
|
||||
})
|
||||
// Backfill authoritative Yolo state for the now-active session so the badge
|
||||
// matches the server even on first connect / multi-client scenarios.
|
||||
ensureYoloStateSynced(instanceId, sessionId)
|
||||
}
|
||||
|
||||
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
|
||||
|
|
@ -510,7 +518,7 @@ function clearActiveParentSession(instanceId: string): void {
|
|||
}
|
||||
|
||||
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
|
||||
let parentToExpand: string | null = null
|
||||
let expandAncestors = false
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.status === status) return false
|
||||
|
|
@ -521,16 +529,12 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
|
|||
session.retry = null
|
||||
}
|
||||
|
||||
// If a child session starts working, auto-expand its parent thread once.
|
||||
// Users can still collapse it afterwards; we only expand on the transition.
|
||||
if (session.parentId && status === "working" && previous !== "working") {
|
||||
parentToExpand = session.parentId
|
||||
expandAncestors = true
|
||||
}
|
||||
})
|
||||
|
||||
if (parentToExpand) {
|
||||
ensureSessionParentExpanded(instanceId, parentToExpand)
|
||||
}
|
||||
if (expandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId)
|
||||
}
|
||||
|
||||
function getActiveParentSession(instanceId: string): Session | null {
|
||||
|
|
@ -565,33 +569,8 @@ function getChildSessions(instanceId: string, parentId: string): Session[] {
|
|||
}
|
||||
|
||||
function getDescendantSessions(instanceId: string, parentId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
const childrenByParent = new Map<string, Session[]>()
|
||||
|
||||
for (const session of allSessions) {
|
||||
if (!session.parentId) continue
|
||||
const children = childrenByParent.get(session.parentId)
|
||||
if (children) {
|
||||
children.push(session)
|
||||
} else {
|
||||
childrenByParent.set(session.parentId, [session])
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: Session[] = []
|
||||
const stack = [...(childrenByParent.get(parentId) ?? [])]
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (stack.length > 0) {
|
||||
const session = stack.shift()
|
||||
if (!session || seen.has(session.id)) continue
|
||||
seen.add(session.id)
|
||||
descendants.push(session)
|
||||
stack.push(...(childrenByParent.get(session.id) ?? []))
|
||||
}
|
||||
|
||||
descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||
return descendants
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions ? getDescendantSessionsFromMap(instanceSessions, parentId) : []
|
||||
}
|
||||
|
||||
function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
||||
|
|
@ -608,112 +587,9 @@ function getSessionRoot(instanceId: string, sessionId: string): Session | null {
|
|||
return getSessionRootFromMap(instanceSessions, sessionId)
|
||||
}
|
||||
|
||||
function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
|
||||
let current = instanceSessions.get(sessionId)
|
||||
if (!current) return null
|
||||
|
||||
const seen = new Set<string>()
|
||||
while (current.parentId) {
|
||||
if (seen.has(current.id)) return null
|
||||
seen.add(current.id)
|
||||
const parent = instanceSessions.get(current.parentId)
|
||||
if (!parent) return null
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
type SessionThreadCacheEntry = {
|
||||
signature: string
|
||||
thread: SessionThread
|
||||
}
|
||||
|
||||
type SessionThreadCache = {
|
||||
byParentId: Map<string, SessionThreadCacheEntry>
|
||||
}
|
||||
|
||||
const sessionThreadCache = new Map<string, SessionThreadCache>()
|
||||
|
||||
function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
|
||||
let cache = sessionThreadCache.get(instanceId)
|
||||
if (!cache) {
|
||||
cache = { byParentId: new Map() }
|
||||
sessionThreadCache.set(instanceId, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: Set<string>): SessionThread[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions || instanceSessions.size === 0 || rootIds.length === 0) {
|
||||
sessionThreadCache.delete(instanceId)
|
||||
return []
|
||||
}
|
||||
|
||||
const cache = getOrCreateSessionThreadCache(instanceId)
|
||||
const seenParents = new Set<string>()
|
||||
|
||||
const childrenByRoot = new Map<string, Session[]>()
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (!session.parentId) continue
|
||||
if (childIds && !childIds.has(session.id)) continue
|
||||
const root = getSessionRootFromMap(instanceSessions, session.id)
|
||||
if (!root) continue
|
||||
const children = childrenByRoot.get(root.id)
|
||||
if (children) {
|
||||
children.push(session)
|
||||
} else {
|
||||
childrenByRoot.set(root.id, [session])
|
||||
}
|
||||
}
|
||||
|
||||
const threads: SessionThread[] = []
|
||||
|
||||
for (const parentId of rootIds) {
|
||||
const parent = instanceSessions.get(parentId)
|
||||
if (!parent || parent.parentId !== null) continue
|
||||
|
||||
seenParents.add(parent.id)
|
||||
|
||||
const children = childrenByRoot.get(parent.id) ?? []
|
||||
if (children.length > 1) {
|
||||
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||
}
|
||||
|
||||
const parentUpdated = parent.time.updated ?? 0
|
||||
const latestChild = children[0]?.time.updated ?? 0
|
||||
const latestUpdated = Math.max(parentUpdated, latestChild)
|
||||
|
||||
const childIds = children.map((child) => child.id).join(",")
|
||||
const signature = `${parentUpdated}:${latestChild}:${childIds}`
|
||||
|
||||
const cached = cache.byParentId.get(parent.id)
|
||||
if (cached && cached.signature === signature) {
|
||||
threads.push(cached.thread)
|
||||
} else {
|
||||
const thread: SessionThread = { parent, children, latestUpdated }
|
||||
cache.byParentId.set(parent.id, { signature, thread })
|
||||
threads.push(thread)
|
||||
}
|
||||
}
|
||||
|
||||
for (const parentId of Array.from(cache.byParentId.keys())) {
|
||||
if (!seenParents.has(parentId)) {
|
||||
cache.byParentId.delete(parentId)
|
||||
}
|
||||
}
|
||||
|
||||
threads.sort((a, b) => {
|
||||
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
|
||||
const bParentUpdated = b.parent.time.updated ?? 0
|
||||
const aParentUpdated = a.parent.time.updated ?? 0
|
||||
if (bParentUpdated !== aParentUpdated) return bParentUpdated - aParentUpdated
|
||||
return b.parent.id.localeCompare(a.parent.id)
|
||||
})
|
||||
|
||||
return threads
|
||||
return instanceSessions ? buildSessionThreadsFromMap(instanceSessions, rootIds, childIds) : []
|
||||
}
|
||||
|
||||
function getSessionThreads(instanceId: string): SessionThread[] {
|
||||
|
|
@ -734,7 +610,7 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] {
|
|||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) continue
|
||||
if (session.parentId === null) {
|
||||
rootIds.push(session.id)
|
||||
if (!rootIds.includes(session.id)) rootIds.push(session.id)
|
||||
} else {
|
||||
childIds.add(session.id)
|
||||
const root = getSessionRootFromMap(instanceSessions, session.id)
|
||||
|
|
@ -745,20 +621,20 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] {
|
|||
return buildSessionThreads(instanceId, rootIds, childIds)
|
||||
}
|
||||
|
||||
function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean {
|
||||
return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId))
|
||||
function isSessionExpanded(instanceId: string, sessionId: string): boolean {
|
||||
return Boolean(expandedSessions().get(instanceId)?.has(sessionId))
|
||||
}
|
||||
|
||||
function setSessionParentExpanded(instanceId: string, parentSessionId: string, expanded: boolean): void {
|
||||
setExpandedSessionParents((prev) => {
|
||||
function setSessionExpanded(instanceId: string, sessionId: string, expanded: boolean): void {
|
||||
setExpandedSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const currentSet = next.get(instanceId) ?? new Set<string>()
|
||||
const updated = new Set(currentSet)
|
||||
|
||||
if (expanded) {
|
||||
updated.add(parentSessionId)
|
||||
updated.add(sessionId)
|
||||
} else {
|
||||
updated.delete(parentSessionId)
|
||||
updated.delete(sessionId)
|
||||
}
|
||||
|
||||
if (updated.size === 0) {
|
||||
|
|
@ -771,16 +647,16 @@ function setSessionParentExpanded(instanceId: string, parentSessionId: string, e
|
|||
})
|
||||
}
|
||||
|
||||
function toggleSessionParentExpanded(instanceId: string, parentSessionId: string): void {
|
||||
setExpandedSessionParents((prev) => {
|
||||
function toggleSessionExpanded(instanceId: string, sessionId: string): void {
|
||||
setExpandedSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const currentSet = next.get(instanceId) ?? new Set<string>()
|
||||
const updated = new Set(currentSet)
|
||||
|
||||
if (updated.has(parentSessionId)) {
|
||||
updated.delete(parentSessionId)
|
||||
if (updated.has(sessionId)) {
|
||||
updated.delete(sessionId)
|
||||
} else {
|
||||
updated.add(parentSessionId)
|
||||
updated.add(sessionId)
|
||||
}
|
||||
|
||||
next.set(instanceId, updated)
|
||||
|
|
@ -788,45 +664,51 @@ function toggleSessionParentExpanded(instanceId: string, parentSessionId: string
|
|||
})
|
||||
}
|
||||
|
||||
function ensureSessionParentExpanded(instanceId: string, parentSessionId: string): void {
|
||||
if (isSessionParentExpanded(instanceId, parentSessionId)) return
|
||||
setSessionParentExpanded(instanceId, parentSessionId, true)
|
||||
function ensureSessionExpanded(instanceId: string, sessionId: string): void {
|
||||
if (isSessionExpanded(instanceId, sessionId)) return
|
||||
setSessionExpanded(instanceId, sessionId, true)
|
||||
}
|
||||
|
||||
function getSessionAncestorIds(instanceId: string, sessionId: string): string[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions ? getSessionAncestorIdsFromMap(instanceSessions, sessionId) : []
|
||||
}
|
||||
|
||||
function ensureSessionAncestorsExpanded(instanceId: string, sessionId: string): void {
|
||||
const ancestorIds = getSessionAncestorIds(instanceId, sessionId)
|
||||
if (ancestorIds.length === 0) return
|
||||
setExpandedSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const expanded = new Set(next.get(instanceId))
|
||||
let changed = false
|
||||
for (const ancestorId of ancestorIds) {
|
||||
if (expanded.has(ancestorId)) continue
|
||||
expanded.add(ancestorId)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return prev
|
||||
next.set(instanceId, expanded)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getVisibleSessionIds(instanceId: string): string[] {
|
||||
const threads = getSessionThreads(instanceId)
|
||||
if (threads.length === 0) return []
|
||||
|
||||
const expanded = expandedSessionParents().get(instanceId)
|
||||
const ids: string[] = []
|
||||
|
||||
for (const thread of threads) {
|
||||
ids.push(thread.parent.id)
|
||||
if (expanded?.has(thread.parent.id)) {
|
||||
for (const child of thread.children) {
|
||||
ids.push(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
const expanded = expandedSessions().get(instanceId)
|
||||
return collectVisibleSessionIds(threads, expanded)
|
||||
}
|
||||
|
||||
function setActiveSessionFromList(instanceId: string, sessionId: string): void {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (session.parentId === null) {
|
||||
setActiveParentSession(instanceId, sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const parentId = session.parentId
|
||||
if (!parentId) return
|
||||
const root = getSessionRoot(instanceId, sessionId)
|
||||
if (!root) return
|
||||
|
||||
batch(() => {
|
||||
setActiveParentSession(instanceId, parentId)
|
||||
setActiveSession(instanceId, sessionId)
|
||||
setActiveParentSession(instanceId, root.id)
|
||||
if (session.id !== root.id) setActiveSession(instanceId, session.id)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -888,9 +770,10 @@ function updateThreadTotalsForParent(instanceId: string, parentSessionId: string
|
|||
}
|
||||
|
||||
function updateThreadTotalsForSession(instanceId: string, sessionId: string): void {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return
|
||||
updateThreadTotalsForParent(instanceId, session.parentId ?? session.id)
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions?.has(sessionId)) return
|
||||
const familyIds = [...getSessionAncestorIdsFromMap(instanceSessions, sessionId), sessionId]
|
||||
for (const familyId of familyIds) updateThreadTotalsForParent(instanceId, familyId)
|
||||
}
|
||||
|
||||
async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise<boolean> {
|
||||
|
|
@ -905,20 +788,20 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
|||
}
|
||||
|
||||
// For a more thorough deep clean, we need to look at actual messages
|
||||
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
return isFreshSession
|
||||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id)
|
||||
messages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
try {
|
||||
const client = getRootClient(instanceId)
|
||||
const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id)
|
||||
messages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
}
|
||||
|
|
@ -932,23 +815,23 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
|||
// Subagent: "blank" (really: finished doing its job) if actually blank...
|
||||
// ... OR no streaming, no pending perms, no tool parts
|
||||
if (messages.length === 0) return true
|
||||
|
||||
|
||||
const hasStreaming = messages.some((msg) => {
|
||||
const info = msg.info.status || msg.status
|
||||
return info === "streaming" || info === "sending"
|
||||
})
|
||||
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastParts = lastMessage?.parts || []
|
||||
const hasToolPart = lastParts.some((part: any) =>
|
||||
const hasToolPart = lastParts.some((part: any) =>
|
||||
part.type === "tool" || part.data?.type === "tool"
|
||||
)
|
||||
|
||||
|
||||
return !hasStreaming && !session.pendingPermission && !hasToolPart
|
||||
} else {
|
||||
// Fork: blank if somehow has no messages or at revert point
|
||||
if (messages.length === 0) return true
|
||||
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastInfo = lastMessage?.info || lastMessage
|
||||
return lastInfo?.id === session.revert?.messageID
|
||||
|
|
@ -1002,6 +885,13 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
|
|||
}
|
||||
}
|
||||
|
||||
// Backward compatibility aliases for renamed exports
|
||||
const expandedSessionParents = expandedSessions
|
||||
const isSessionParentExpanded = isSessionExpanded
|
||||
const setSessionParentExpanded = setSessionExpanded
|
||||
const toggleSessionParentExpanded = toggleSessionExpanded
|
||||
const ensureSessionParentExpanded = ensureSessionExpanded
|
||||
|
||||
export {
|
||||
sessions,
|
||||
setSessions,
|
||||
|
|
@ -1036,7 +926,7 @@ export {
|
|||
markViewedSessionIdleSeen,
|
||||
setSessionStatus,
|
||||
setActiveSession,
|
||||
|
||||
|
||||
setActiveParentSession,
|
||||
|
||||
clearActiveParentSession,
|
||||
|
|
@ -1051,10 +941,12 @@ export {
|
|||
getSessionThreads,
|
||||
getSessionSearchThreads,
|
||||
getVisibleSessionIds,
|
||||
isSessionParentExpanded,
|
||||
setSessionParentExpanded,
|
||||
toggleSessionParentExpanded,
|
||||
ensureSessionParentExpanded,
|
||||
isSessionExpanded,
|
||||
setSessionExpanded,
|
||||
toggleSessionExpanded,
|
||||
ensureSessionExpanded,
|
||||
getSessionAncestorIds,
|
||||
ensureSessionAncestorsExpanded,
|
||||
setActiveSessionFromList,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
|
|
@ -1062,6 +954,11 @@ export {
|
|||
getSessionInfo,
|
||||
isBlankSession,
|
||||
cleanupBlankSessions,
|
||||
expandedSessionParents,
|
||||
isSessionParentExpanded,
|
||||
setSessionParentExpanded,
|
||||
toggleSessionParentExpanded,
|
||||
ensureSessionParentExpanded,
|
||||
SESSION_PAGE_SIZE,
|
||||
sessionPagination,
|
||||
sessionSearch,
|
||||
|
|
|
|||
109
packages/ui/src/stores/session-tree.test.ts
Normal file
109
packages/ui/src/stores/session-tree.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import type { Session } from "../types/session"
|
||||
import {
|
||||
buildSessionThreadsFromMap,
|
||||
collectSessionThreadIds,
|
||||
collectVisibleSessionIds,
|
||||
findSessionThread,
|
||||
getDescendantSessionsFromMap,
|
||||
getSessionAncestorIdsFromMap,
|
||||
getSessionRootFromMap,
|
||||
sortSessionIdsDeepestFirst,
|
||||
} from "./session-tree"
|
||||
|
||||
function session(id: string, parentId: string | null, updated: number): Session {
|
||||
return { id, parentId, time: { created: updated, updated } } as Session
|
||||
}
|
||||
|
||||
function sessionMap(definitions: Array<[string, string | null, number]>): Map<string, Session> {
|
||||
return new Map(definitions.map(([id, parentId, updated]) => [id, session(id, parentId, updated)]))
|
||||
}
|
||||
|
||||
describe("session tree", () => {
|
||||
it("preserves nesting and sorts siblings by descendant activity", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["older-branch", "root", 200],
|
||||
["newer-branch", "root", 300],
|
||||
["deep-active", "older-branch", 500],
|
||||
])
|
||||
|
||||
const [root] = buildSessionThreadsFromMap(sessions, ["root"])
|
||||
assert.equal(root.latestUpdated, 500)
|
||||
assert.deepEqual(root.children.map((child) => child.session.id), ["older-branch", "newer-branch"])
|
||||
assert.equal(root.children[0].children[0].session.id, "deep-active")
|
||||
assert.equal(root.children[0].children[0].depth, 2)
|
||||
})
|
||||
|
||||
it("includes the complete ancestor path for filtered descendants", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["child", "root", 200],
|
||||
["grandchild", "child", 300],
|
||||
["sibling", "root", 400],
|
||||
])
|
||||
|
||||
const [root] = buildSessionThreadsFromMap(sessions, ["root"], new Set(["grandchild"]))
|
||||
assert.deepEqual(root.children.map((child) => child.session.id), ["child"])
|
||||
assert.deepEqual(root.children[0].children.map((child) => child.session.id), ["grandchild"])
|
||||
assert.equal(buildSessionThreadsFromMap(sessions, ["root", "root"]).length, 1)
|
||||
})
|
||||
|
||||
it("only exposes descendants whose full parent path is expanded", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["child", "root", 200],
|
||||
["grandchild", "child", 300],
|
||||
])
|
||||
const threads = buildSessionThreadsFromMap(sessions, ["root"])
|
||||
|
||||
assert.deepEqual(collectVisibleSessionIds(threads, undefined), ["root"])
|
||||
assert.deepEqual(collectVisibleSessionIds(threads, new Set(["root"])), ["root", "child"])
|
||||
assert.deepEqual(collectVisibleSessionIds(threads, new Set(["root", "child"])), ["root", "child", "grandchild"])
|
||||
})
|
||||
|
||||
it("resolves roots and ancestors across arbitrary depth", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["child", "root", 200],
|
||||
["grandchild", "child", 300],
|
||||
])
|
||||
|
||||
assert.equal(getSessionRootFromMap(sessions, "grandchild")?.id, "root")
|
||||
assert.deepEqual(getSessionAncestorIdsFromMap(sessions, "grandchild"), ["root", "child"])
|
||||
})
|
||||
|
||||
it("terminates safely for cycles and missing parents", () => {
|
||||
const sessions = sessionMap([
|
||||
["a", "b", 100],
|
||||
["b", "a", 200],
|
||||
["orphan", "missing", 300],
|
||||
])
|
||||
|
||||
assert.equal(getSessionRootFromMap(sessions, "a"), null)
|
||||
assert.equal(getSessionRootFromMap(sessions, "orphan"), null)
|
||||
assert.deepEqual(getSessionAncestorIdsFromMap(sessions, "a"), [])
|
||||
assert.deepEqual(getDescendantSessionsFromMap(sessions, "a").map((item) => item.id), ["b"])
|
||||
assert.deepEqual(buildSessionThreadsFromMap(sessions, ["a", "orphan"]), [])
|
||||
})
|
||||
|
||||
it("collects an intermediate subtree and orders deletion children before parents", () => {
|
||||
const sessions = sessionMap([
|
||||
["root", null, 100],
|
||||
["child", "root", 200],
|
||||
["grandchild", "child", 300],
|
||||
["sibling", "root", 400],
|
||||
])
|
||||
const threads = buildSessionThreadsFromMap(sessions, ["root"])
|
||||
const child = findSessionThread(threads, "child")
|
||||
|
||||
assert.ok(child)
|
||||
assert.deepEqual(collectSessionThreadIds([child]), ["child", "grandchild"])
|
||||
assert.deepEqual(
|
||||
sortSessionIdsDeepestFirst(sessions, ["root", "child", "grandchild"]),
|
||||
["grandchild", "child", "root"],
|
||||
)
|
||||
})
|
||||
})
|
||||
161
packages/ui/src/stores/session-tree.ts
Normal file
161
packages/ui/src/stores/session-tree.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import type { Session } from "../types/session"
|
||||
|
||||
export type SessionThread = {
|
||||
session: Session
|
||||
children: SessionThread[]
|
||||
depth: number
|
||||
hasChildren: boolean
|
||||
latestUpdated: number
|
||||
}
|
||||
|
||||
export function getSessionRootFromMap(instanceSessions: Map<string, Session>, sessionId: string): Session | null {
|
||||
let current = instanceSessions.get(sessionId)
|
||||
if (!current) return null
|
||||
|
||||
const seen = new Set<string>()
|
||||
while (current.parentId) {
|
||||
if (seen.has(current.id)) return null
|
||||
seen.add(current.id)
|
||||
const parent = instanceSessions.get(current.parentId)
|
||||
if (!parent) return null
|
||||
current = parent
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function getSessionAncestorIdsFromMap(instanceSessions: Map<string, Session>, sessionId: string): string[] {
|
||||
const ancestors: string[] = []
|
||||
const seen = new Set<string>([sessionId])
|
||||
let current = instanceSessions.get(sessionId)
|
||||
while (current?.parentId) {
|
||||
if (seen.has(current.parentId)) return []
|
||||
seen.add(current.parentId)
|
||||
const parent = instanceSessions.get(current.parentId)
|
||||
if (!parent) return []
|
||||
ancestors.push(parent.id)
|
||||
current = parent
|
||||
}
|
||||
ancestors.reverse()
|
||||
return ancestors
|
||||
}
|
||||
|
||||
export function getDescendantSessionsFromMap(instanceSessions: Map<string, Session>, parentId: string): Session[] {
|
||||
const childrenByParent = new Map<string, Session[]>()
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (!session.parentId) continue
|
||||
const children = childrenByParent.get(session.parentId)
|
||||
if (children) children.push(session)
|
||||
else childrenByParent.set(session.parentId, [session])
|
||||
}
|
||||
|
||||
const descendants: Session[] = []
|
||||
const queue = [...(childrenByParent.get(parentId) ?? [])]
|
||||
const seen = new Set<string>([parentId])
|
||||
while (queue.length > 0) {
|
||||
const session = queue.shift()
|
||||
if (!session || seen.has(session.id)) continue
|
||||
seen.add(session.id)
|
||||
descendants.push(session)
|
||||
queue.push(...(childrenByParent.get(session.id) ?? []))
|
||||
}
|
||||
descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
|
||||
return descendants
|
||||
}
|
||||
|
||||
function buildThread(
|
||||
session: Session,
|
||||
childrenByParent: Map<string, Session[]>,
|
||||
depth: number,
|
||||
ancestorIds: Set<string>,
|
||||
): SessionThread | null {
|
||||
if (ancestorIds.has(session.id)) return null
|
||||
const nextAncestorIds = new Set(ancestorIds)
|
||||
nextAncestorIds.add(session.id)
|
||||
|
||||
const children: SessionThread[] = []
|
||||
for (const child of childrenByParent.get(session.id) ?? []) {
|
||||
const childThread = buildThread(child, childrenByParent, depth + 1, nextAncestorIds)
|
||||
if (childThread) children.push(childThread)
|
||||
}
|
||||
children.sort((a, b) => {
|
||||
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
|
||||
return b.session.id.localeCompare(a.session.id)
|
||||
})
|
||||
|
||||
let latestUpdated = session.time.updated ?? 0
|
||||
for (const child of children) latestUpdated = Math.max(latestUpdated, child.latestUpdated)
|
||||
return { session, children, depth, hasChildren: children.length > 0, latestUpdated }
|
||||
}
|
||||
|
||||
export function buildSessionThreadsFromMap(
|
||||
instanceSessions: Map<string, Session>,
|
||||
rootIds: string[],
|
||||
includedDescendantIds?: Set<string>,
|
||||
): SessionThread[] {
|
||||
let includedIds: Set<string> | null = null
|
||||
if (includedDescendantIds) {
|
||||
includedIds = new Set(rootIds)
|
||||
for (const sessionId of includedDescendantIds) {
|
||||
includedIds.add(sessionId)
|
||||
for (const ancestorId of getSessionAncestorIdsFromMap(instanceSessions, sessionId)) includedIds.add(ancestorId)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, Session[]>()
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (!session.parentId || (includedIds && !includedIds.has(session.id))) continue
|
||||
const children = childrenByParent.get(session.parentId)
|
||||
if (children) children.push(session)
|
||||
else childrenByParent.set(session.parentId, [session])
|
||||
}
|
||||
|
||||
const threads: SessionThread[] = []
|
||||
const seenRootIds = new Set<string>()
|
||||
for (const rootId of rootIds) {
|
||||
if (seenRootIds.has(rootId)) continue
|
||||
seenRootIds.add(rootId)
|
||||
const root = instanceSessions.get(rootId)
|
||||
if (!root || root.parentId !== null) continue
|
||||
const thread = buildThread(root, childrenByParent, 0, new Set())
|
||||
if (thread) threads.push(thread)
|
||||
}
|
||||
threads.sort((a, b) => {
|
||||
if (b.latestUpdated !== a.latestUpdated) return b.latestUpdated - a.latestUpdated
|
||||
const updatedDelta = (b.session.time.updated ?? 0) - (a.session.time.updated ?? 0)
|
||||
return updatedDelta || b.session.id.localeCompare(a.session.id)
|
||||
})
|
||||
return threads
|
||||
}
|
||||
|
||||
export function collectVisibleSessionIds(threads: SessionThread[], expanded: Set<string> | undefined): string[] {
|
||||
const ids: string[] = []
|
||||
for (const thread of threads) {
|
||||
ids.push(thread.session.id)
|
||||
if (expanded?.has(thread.session.id)) ids.push(...collectVisibleSessionIds(thread.children, expanded))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function findSessionThread(threads: SessionThread[], sessionId: string): SessionThread | null {
|
||||
for (const thread of threads) {
|
||||
if (thread.session.id === sessionId) return thread
|
||||
const child = findSessionThread(thread.children, sessionId)
|
||||
if (child) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectSessionThreadIds(threads: SessionThread[]): string[] {
|
||||
const ids: string[] = []
|
||||
for (const thread of threads) {
|
||||
ids.push(thread.session.id)
|
||||
ids.push(...collectSessionThreadIds(thread.children))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export function sortSessionIdsDeepestFirst(instanceSessions: Map<string, Session>, sessionIds: string[]): string[] {
|
||||
return [...sessionIds].sort(
|
||||
(left, right) => getSessionAncestorIdsFromMap(instanceSessions, right).length - getSessionAncestorIdsFromMap(instanceSessions, left).length,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ import {
|
|||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
ensureSessionParentExpanded,
|
||||
ensureSessionAncestorsExpanded,
|
||||
ensureSessionExpanded,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
|
|
@ -28,7 +29,7 @@ import {
|
|||
getVisibleSessionIds,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
isSessionParentExpanded,
|
||||
isSessionExpanded,
|
||||
loading,
|
||||
markSessionIdleSeen,
|
||||
markViewedSessionIdleSeen,
|
||||
|
|
@ -39,9 +40,9 @@ import {
|
|||
setActiveSession,
|
||||
setActiveSessionFromList,
|
||||
setSessionDraftPrompt,
|
||||
setSessionParentExpanded,
|
||||
setSessionExpanded,
|
||||
setSessionStatus,
|
||||
toggleSessionParentExpanded,
|
||||
toggleSessionExpanded,
|
||||
clearSessionSearch,
|
||||
getSessionHasMore,
|
||||
isSessionSearchLoading,
|
||||
|
|
@ -112,7 +113,8 @@ export {
|
|||
clearSessionDraftPrompt,
|
||||
createSession,
|
||||
deleteSession,
|
||||
ensureSessionParentExpanded,
|
||||
ensureSessionAncestorsExpanded,
|
||||
ensureSessionExpanded,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
|
|
@ -141,7 +143,7 @@ export {
|
|||
getVisibleSessionIds,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
isSessionParentExpanded,
|
||||
isSessionExpanded,
|
||||
loadMessages,
|
||||
loading,
|
||||
markSessionIdleSeen,
|
||||
|
|
@ -154,9 +156,9 @@ export {
|
|||
setActiveSession,
|
||||
setActiveSessionFromList,
|
||||
setSessionDraftPrompt,
|
||||
setSessionParentExpanded,
|
||||
setSessionExpanded,
|
||||
setSessionStatus,
|
||||
toggleSessionParentExpanded,
|
||||
toggleSessionExpanded,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
clearSessionSearch,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { sessions } from "./session-state"
|
||||
import { getSessionRoot, sessions } from "./session-state"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getCodeNomadSessionMetadata, setSessionWorktreeSlugWithClient } from "./session-metadata"
|
||||
import { getRootClient } from "./opencode-client"
|
||||
|
|
@ -283,9 +283,7 @@ function getDefaultWorktreeSlug(instanceId: string): string {
|
|||
}
|
||||
|
||||
function getParentSessionId(instanceId: string, sessionId: string): string {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return sessionId
|
||||
return session.parentId ?? session.id
|
||||
return getSessionRoot(instanceId, sessionId)?.id ?? sessionId
|
||||
}
|
||||
|
||||
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@
|
|||
.delete-hover-scope[data-delete-part-hover="true"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
inset: -4px;
|
||||
background: var(--status-error-bg);
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
/* Overlay must sit above the part card background. */
|
||||
z-index: 10;
|
||||
}
|
||||
.message-reasoning-card[data-delete-part-hover="true"]::before {
|
||||
inset: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,36 +268,36 @@ session-sidebar-controls .selector-trigger-primary {
|
|||
}
|
||||
|
||||
|
||||
.session-item-base.session-item-child {
|
||||
padding-inline-start: 2.25rem;
|
||||
.session-item-base.session-item-nested {
|
||||
padding-inline-start: var(--session-indent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child::before {
|
||||
.session-item-base.session-item-nested::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 1.125rem;
|
||||
inset-inline-start: var(--session-connector-offset);
|
||||
width: 1px;
|
||||
background-color: var(--text-secondary);
|
||||
opacity: 0.95;
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child.session-item-child-last::before {
|
||||
.session-item-base.session-item-nested.session-item-child-last::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.session-item-base.session-item-child::after {
|
||||
.session-item-base.session-item-nested::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-start: 1.125rem;
|
||||
inset-inline-start: var(--session-connector-offset);
|
||||
width: 0.875rem;
|
||||
height: 1px;
|
||||
background-color: var(--text-secondary);
|
||||
opacity: 0.95;
|
||||
opacity: 0.65;
|
||||
transform: translateY(-0.5px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue