Compare commits

...

9 commits
v0.18.0 ... dev

Author SHA1 Message Date
bluelovers
f34e807108
feat: detect realpath when open workspace (#486)
detect realpath when open workspace

try avoid path issue in windows

- d:\xxx
- D:\xxx
- symbol link

now they are same path when try detect exists workspace

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 23:42:16 +02:00
Shantur Rathore
c8d4a5099a
build: publish deb and portable tar.gz Linux artifacts (#492)
## Summary
- Publish one supported installer on Linux: a Tauri .deb package for
x64.
- Publish one portable fallback: an Electron .tar.gz archive for x64.
- Remove the temporary Flatpak path and stop publishing Linux AppImage,
RPM, and zip variants.
- Remove obsolete Linux assets from reused releases before uploading
replacements.
- Closes #487, Closes #488

## Artifact validation
- Extract the Electron archive and verify its executable, bundled server
resources, app.asar renderer entry, referenced assets, and
shared-library closure.
- Verify Tauri Debian metadata, bundled resources, desktop entry, shared
libraries, and package installation in Ubuntu 24.04.
- Synchronize Tauri package versions before every platform build so
generated metadata follows release inputs.

## Validation
- Parsed the workflow YAML and Electron package JSON.
- Verified the local @electron/asar APIs used by CI.
- Ran the Tauri version synchronization script.
- Ran git diff --check.
- Reviewed the final diff against current dev; full Linux packaging and
Docker installation are delegated to PR CI.

## Compatibility
The Tauri deb is built and installation-tested on Ubuntu 24.04. Older
Debian-based distributions are not yet guaranteed.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 19:29:33 +02:00
VooDisss
56d1e00f2f
PR: Extend delete overlays and clean companion parts during tool deletion (#247)
# PR: Extend delete overlays and clean companion parts during tool
deletion
Fixes: #246 
## Purpose
This PR addresses incomplete deletions when users bulk-delete specific
tool calls from the timeline. Previously, deleting a tool part left
behind orphaned reasoning logs (thinking tokens) and generated text
parts, and the destructive hover overlay only highlighted the tool cards
themselves.

This update ensures that deleting tool parts cleans up the entire
associated response context (`step-finish`, `reasoning`, and `text`
parts) from that message. It also extends the red delete overlay to
message headers, reasoning cards, and text parts to accurately reflect
the scope of deletion to the user.

## Changes by File

### `packages/ui/src/components/message-block.tsx`
- Threaded the `selectedToolPartKeys` prop through `MessageContentItem`,
`MessageBlock`, and `ReasoningCard`.
- Updated `ReasoningCard`'s `isSelectedForDeletion` logic to check both
`selectedMessageIds` (whole message) and `selectedToolPartKeys`
(tool-part selection via a `messageId:` prefix match).
- Moved the block-level selection highlight out of
`isDeleteMessageHovered` to be handled at the part level via
`data-delete-part-hover` attributes avoiding double overlays.
- Added `data-delete-part-hover` to the `ReasoningCard`'s root `div`.

### `packages/ui/src/components/message-item.tsx`
- Added the `selectedToolPartKeys` prop to `MessageItemProps`.
- Expanded `isSelectedForDeletion` using the same prefix-match pattern
to detect tool-part selections.
- The `<header>` element now includes a `delete-hover-scope` class and
`data-delete-part-hover` attribute to trigger the overlay at the header
level.
- Each `.message-part-shell` rendering text content now includes
`data-part-type` and `data-delete-part-hover` for per-part overlay
highlighting.

### `packages/ui/src/components/message-section.tsx`
- Updated the `deleteSelectedMessages` bulk action. It now additionally
collects and deletes `step-finish`, `reasoning`, and `text` parts when a
message has its tool parts selected for deletion. This ensures no
orphaned assistant content remains.
- Simplified the deletion guard condition from `selectedToolPartIds.size
=== 0 || stepFinishPartIds.length === 0` down to
`selectedToolPartIds.size === 0`, ensuring reasoning/text parts evaluate
correctly even if `step-finish` parts are absent.

### `packages/ui/src/styles/messaging/delete-overlays.css`
- Widened the part-level overlay inset from `-2px` to `-4px` for visual
consistency with the message-level overlay.
- Added a new rule for `.message-reasoning-card[data-delete-part-hover]`
using `inset: 0` for flush overlay rendering within reasoning card
boundaries.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 18:40:28 +02:00
Joe Huss
34706228bb
feat(ui): implement recursive nested session expansion (#478)
This change enables the UI to display nested sessions within nested
sessions in a foldable display, recursively, up to 10 levels deep.

## What Changed

### Core Data Structure (session-state.ts)
- Redefined `SessionThread` type to support true recursive nesting:
- Old: `{ parent: Session, children: Session[], latestUpdated: number }`
- New: `{ session: Session, children: SessionThread[], depth: number,
hasChildren: boolean, latestUpdated: number }`
- Renamed `expandedSessionParents` signal to `expandedSessions` to
reflect that ANY session with children can now be expanded, not just
top-level parents
- Updated `getSessionThreads()` to build a recursive tree structure
using new `buildSessionThreadTree()` and `computeThreadSignature()`
helpers
- Updated `getSessionFamily()` to recursively collect ALL descendants,
not just direct children
- Updated `getVisibleSessionIds()` with `collectVisibleSessionIds()`
helper to recursively collect visible session IDs based on expansion
state
- Maintained backward compatibility aliases for renamed functions:
`isSessionParentExpanded`, `setSessionParentExpanded`, etc.

### Session State Exports (sessions.ts)
- Updated imports and exports to use the new function names:
`ensureSessionExpanded`, `isSessionExpanded`, `setSessionExpanded`,
`toggleSessionExpanded`

### Session Events (session-events.ts)
- Updated `ensureSessionParentExpanded` → `ensureSessionExpanded` in
auto-expand logic for child sessions that start working

### Permission Modal (permission-approval-modal.tsx)
- Updated import and usage of `ensureSessionParentExpanded` →
`ensureSessionExpanded`

### UI Rendering (session-list.tsx)
- Updated `SessionRow` component to accept `session` object directly
instead of `sessionId`, plus `depth` and `isLastChild` props
- Derived `isChild` from `depth > 0` instead of explicit prop
- Added `depthClass()` for CSS depth-based indentation
- Created new `SessionThreadRow` recursive component that:
  - Renders the current session via SessionRow
- If expanded and has children, recursively renders children with
increased depth
- Updated `filteredThreads` with `subtreeHasMatch()` and
`filterThreadTree()` helpers for recursive filtering
- Updated `allMatchingSessionIds` with `collectThreadIds()` helper for
recursive ID collection
- Removed child-specific `Bot` icon - all sessions now use `User` icon
- Updated expander visibility to show for ANY session with children,
regardless of depth

### Styling (session-layout.css)
- Added depth-based CSS classes `.session-item-depth-{1-10}` with:
- Progressive indentation: 2.25rem for depth 1, up to 13.5rem for depth
10
  - Tree connector styling via `::before` and `::after` pseudo-elements
  - Proper vertical line handling for last-child at each depth level

### Tests (session-state.test.ts)
- Added comprehensive test suite (683 lines) covering:
- `getSessionThreads`: empty sessions, single sessions, single-level
children, multi-level nested children, sorting, hasChildren computation
  - `getSessionFamily`: recursive descendant collection
  - Expansion state: toggle, explicit set, ensure logic
- `getVisibleSessionIds`: visibility based on expansion state at
multiple levels

## User-Facing Behavior
- Nested sessions can now be collapsed/expanded at any depth level
- A chevron expander appears on any session that has children
- Children are indented based on their nesting depth
- Tree lines connect parent-child relationships visually
- Expanding a parent auto-expands ancestors when selecting a deeply
nested child session

## Edge Cases Handled
- Sessions with no children show no expander
- Last child at each depth level has shortened vertical tree line
- Thread sorting by latestUpdated works correctly with nested updates
- Cache invalidation properly tracks thread changes at all depth levels

## Implementation Notes
- Depth is limited to 10 levels to prevent excessive indentation
- The `hasChildren` flag is computed once during tree building for
performance
- The Session type's `parentId` field already supported arbitrary
nesting - only the UI rendering needed to be updated

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-12 01:18:31 +02:00
heunghingwan
ca06bd99d7
feat(yolo): move permission auto-accept (Yolo) to the server (#561)
## Summary

Yolo (permission auto-accept) currently lives entirely in the UI. Each
browser keeps its own toggle in `localStorage` and auto-replies to
permission requests over a 4-hop path (`OpenCode → server SSE → UI →
server proxy → OpenCode`). The server — which already sits on the event
stream that carries every `permission.asked` — does none of the work.

This PR makes the **server authoritative**: it owns the toggle state,
resolves family-root inheritance, and performs the auto-reply in-process
via loopback using the same `"once"` semantics the UI used to send. The
UI becomes a pure view: it toggles via REST and mirrors state from a
`yolo.stateChanged` SSE event.

## Why

- **Correctness**: the server already consumes the instance SSE stream
(`InstanceEventBridge`); auto-accepting there is the natural choke point
instead of bouncing to the UI and back.
- **Multi-client**: previously each browser had independent
`localStorage` state and never synced. Toggles now broadcast to all
connected clients in real time.
- **Headless**: Yolo keeps auto-accepting even when no UI is connected
(useful for long autonomous runs).
- **Latency**: drops from 4 hops to a single in-process loopback call.

## What changed

**Server (new, authoritative)**
- `permissions/auto-accept-store.ts` — in-memory state keyed by family
root. Faithful port of `resolvePermissionAutoAcceptFamilyRoot`:
fork/`revert` sessions root at themselves; enabling any member enables
the whole family.
- `permissions/auto-accept-manager.ts` — subscribes to `instance.event`,
builds the session tree from `session.created/updated/deleted`
(`properties.info`), intercepts `permission.v2.asked` /
`permission.asked`, dedupes in-flight replies, emits `yolo.stateChanged`
/ `yolo.autoAccepted`, clears per-instance state on
`workspace.stopped/error`.
- `permissions/opencode-replier.ts` — default replier calling OpenCode
directly (`getInstancePort` + auth header), mirroring
`background-processes/manager.ts`.
- `server/routes/yolo.ts` — `GET/POST
/workspaces/:id/yolo/sessions/:sid[/toggle]`, following existing route
conventions.
- `api-types.ts` / `events/bus.ts` — `YoloStateResponse` + the two new
event types registered in `onEvent` so they flow over `/api/events`.

**UI (pure view)**
- `permission-auto-accept.ts` — removed `localStorage`, persistence, and
drain logic; now a runtime (non-persisted) projection.
`resolvePermissionAutoAcceptFamilyRoot` is **retained as a display aid**
so the badge still lights up for child/sub-sessions of an enabled family
(preserving the inheritance UX).
- `instances.ts` — toggle calls REST (optimistic, reconciled on success,
reverted on failure); subscribes to `yolo.stateChanged`;
`ensureYoloStateSynced` backfills the active session's state on first
connect (deduped per session, reset on SSE reconnect so it re-syncs
after a server restart).
- `session-events.ts` — removed the three
`drainAutoAcceptPermissionsForInstance` hooks (the server drains now).
- `api-client.ts` — `getYoloState` / `toggleYolo`.

## Behavior parity

| Aspect | Before | After |
|---|---|---|
| Reply semantics | `"once"` | `"once"` (unchanged) |
| Inheritance | whole family (root + non-fork descendants) | **same** |
| Fork isolation | `revert` session is its own root | **same** |
| Persistence | UI `localStorage` | none (intentional, see Notes) |
| Multi-client sync |  independent per browser |  real-time via SSE |
| Works with UI closed |  |  |
| Auto-reply path | 4 hops | 1 in-process hop |

## Testing

32 unit tests added (`node:test`), all passing. Server + UI typecheck
clean.

- **Store (16)**: inheritance (parent/child/sibling), fork isolation,
cyclic parent chains, late parent discovery, `revert` re-rooting,
per-instance independence, tree maintenance.
- **Manager (13)**: real `properties.info` event shapes, v2 vs legacy
reply, in-flight dedup + retry-after-resolve, `yolo.stateChanged`
emission, `session.deleted` keeps toggle, `workspace.stopped` clears
state, `stop()` unsubscribes.
- **UI (3)**: retained `resolvePermissionAutoAcceptFamilyRoot`
display-projection tests.

## Notes for reviewers

- **No persistence is intentional** for this milestone — server restart
resets all Yolo state (matches the agreed scope). The UI mirror
self-heals via SSE reconnect + `ensureYoloStateSynced`. Persistence can
be layered on later (e.g. into `~/.config/codenomad/config.json`)
without touching the manager.
- **Family-root resolution lives in two places on purpose**: the server
resolves it to decide whether to auto-reply; the UI resolves the same
pure function to render the badge instantly (synchronous memo). Both are
faithful ports; no network round-trip is added for display.
- **`properties.info` nesting**: OpenCode wraps session records under
`properties.info` for `session.*` events (permission events are flat).
The manager handles both and the tests use the real nested shape to
guard against regressions.
- `getYoloState` / `yolo.autoAccepted` are wired but `yolo.autoAccepted`
is not yet consumed by the UI — it's available on the wire for future
observability (e.g. an audit log / toast).

## Risk / rollback

The change is additive on the server and the UI gracefully degrades to
the SSE mirror. If the server lacks the new routes (mixed-version), the
UI's optimistic toggle still flips locally and `toggleYolo` failures are
logged + reverted, so no hard breakage.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-08 21:13:44 +02:00
Pascal André
bce975d940
fix(ui): stabilize strict follow autoscroll (#576)
## Summary
- replace message stream follow behavior with a strict following/escaped
state model
- keep explicit submit bottom pinning deterministic while preserving
user scroll-up escape intent
- prevent repeated pending question/permission polling from rewriting
identical store entries and snapping back to bottom
- isolate pending request dedupe logic in a raw-node-testable helper

Fixes #574

## Validation
- node --test
"packages/ui/src/components/virtual-follow-behavior.test.ts"
"packages/ui/src/components/session/session-bottom-pin-intent.test.ts"
"packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts"
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
- git diff --check
2026-07-08 18:46:55 +02:00
Dark
275770b51b
fix(ui): remove scope=project from session list requests (#572)
## Problem

PR #565 (commit e29c3a08) added `scope=project` to session list requests
so worktree/project sessions remain visible. With OpenCode 1.17.x, the
`/session` endpoint can ignore the `directory` filter when
`scope=project` is present and return sessions from unrelated folders
that share the same project/global bucket.

This can make sessions appear in the wrong project tab. Deleting one of
those sessions still deletes it from the shared OpenCode database, so the
UI must avoid showing unrelated sessions in the wrong tab.

## Reproduction

```bash
PORT=4096

curl -s -u "codenomad:$PASSWORD" \
  "http://127.0.0.1:$PORT/session?directory=/home/user/project-a&scope=project" \
  | jq 'length'
# Can include sessions from other folders too.

curl -s -u "codenomad:$PASSWORD" \
  "http://127.0.0.1:$PORT/session?directory=/home/user/project-a" \
  | jq 'length'
# Filters to the requested folder, but may miss project/worktree sessions.

Related: anomalyco/opencode#33113

Fix
Keep scope: "project" so OpenCode still returns project/worktree
sessions, then filter the returned sessions client-side to the current
root directory plus known CodeNomad worktree directories.

This preserves the worktree-session visibility behavior from #565 while
avoiding unrelated cross-directory leakage when OpenCode ignores
directory alongside scope=project.

Validation
Focused session-pagination tests pass.
UI typecheck passes.
UI build passes.
Tauri release build passes.
Manual exe tested for validation.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-07-08 17:56:12 +02:00
Dark
e8623d3132
fix(ui): SSE connection status indicators for mobile network drops (#549)
## Problem

Mobile users experience SSE disconnections (wifi drops, Cloudflare idle
timeouts) but get no visual feedback. The per-instance status dots
remain green even when the SSE transport is down, because instance
status updates travel over the broken SSE pipe.

## Solution

This PR implements **immediate connection status feedback** by
propagating SSE transport state to all instance status indicators:

### Changes

1. **server-events.ts**: Add `onDisconnect()` handler
   - Mirrors existing `onOpen()` pattern
- Fire `disconnectHandlers` in `scheduleReconnect()` when connection
drops

2. **sse-manager.ts**: Register SSE lifecycle handlers
   - `onDisconnect`: set ALL instances to `connecting` (amber dot)
- `onOpen`: clear `connecting` status (green dot restored by subsequent
events)
   - Log transitions for debug visibility

3. **AGENTS.md**: Add PR Review Principles
   - Check regressions first, look for better implementations
   - Be the PR gatekeeper, ruthless code quality
   - Test before responding, UI/server version parity

## Verification

-  TypeScript compilation clean (pre-existing SDK errors unrelated)
-  Vite build successful
-  Mobile testing: SSE disconnect → immediate amber dots
-  Reconnect → green dots restored

## Edge Cases Handled

- **Transient drop**: amber → green on reconnect (no false disconnect
modal)
- **Instance dies during outage**: amber → green → `disconnected` event
→ red
- **0 instances**: Map empty, loop is no-op
- **Rapid reconnect cycles**: idempotent (setting `connecting` on
`connecting` is no-op)

## Related

- Complements PR #519 (pong retry with timeout) - merged upstream
- Addresses mobile UX gap: workspace-level SSE state now visible to all
users (not just debug overlay)

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
Co-authored-by: Pascal André <pascalandr@gmail.com>
2026-06-27 20:39:13 +02:00
Shantur Rathore
31414521f6 Min server 0.18.0 2026-06-21 20:09:49 +01:00
71 changed files with 4342 additions and 2268 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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.

View file

@ -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
View file

@ -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",

View file

@ -1,4 +1,4 @@
{
"minServerVersion": "0.17.0",
"minServerVersion": "0.18.0",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View file

@ -131,12 +131,10 @@
"createStartMenuShortcut": true
},
"linux": {
"executableName": "CodeNomad",
"target": [
{
"target": "zip"
},
{
"target": "AppImage"
"target": "tar.gz"
}
],
"artifactName": "CodeNomad-Electron-linux-${arch}-${version}.${ext}",

View file

@ -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",

View file

@ -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

View file

@ -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)
}
}
}

View 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))
}

View 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
}

View 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)
})
})

View 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)
}
}
}
}

View 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,
)
}
}
}

View file

@ -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()
},

View file

@ -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"

View 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 }
},
)
}

View file

@ -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)
})
})

View 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 } : {}),
})
}

View file

@ -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

View 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),
}
}
}

View file

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,

View file

@ -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

View file

@ -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 {

View file

@ -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">

View file

@ -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}

View file

@ -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)

View file

@ -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)}

View file

@ -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,
)
})
})

View file

@ -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
}

View file

@ -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,
)
})
})

View file

@ -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
}

View file

@ -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}

View file

@ -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",
})

View 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"])
})
})

View 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
}

View file

@ -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

View file

@ -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

View file

@ -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",

View 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")
})
})

View 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
}

View file

@ -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()

View file

@ -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)

View file

@ -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]) {

View file

@ -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"])
})
})

View file

@ -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)
})
},

View file

@ -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()

View file

@ -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() {

View file

@ -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,

View file

@ -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)
})
})

View file

@ -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)

View file

@ -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 })
})
})

View 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),
)
}

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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) =>

View file

@ -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[] {

View file

@ -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)
}
}
}

View file

@ -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)
})
}

View file

@ -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)

View file

@ -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,

View 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"],
)
})
})

View 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,
)
}

View file

@ -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,

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;
}