Compare commits

..

37 Commits

Author SHA1 Message Date
Shantur Rathore
147c9e3e4b Bump version to 0.6.0 2026-01-09 21:46:14 +00:00
Shantur Rathore
ab38cdccac fix(ui): simplify prompt input hints 2026-01-09 21:10:00 +00:00
Shantur Rathore
8168d52295 chore(config): bump @opencode-ai/plugin
Update opencode config plugin dependency to 1.1.8.
2026-01-09 18:56:51 +00:00
Shantur Rathore
1081bfb276 fix(ui): keep session view after delete
When deleting the active session from the sidebar list, automatically select a nearby visible session instead of falling back to the info view.
2026-01-09 18:53:00 +00:00
Shantur Rathore
38064b229c ui: move instance info to drawer header
Remove the Instance section from the session list and replace it with an info icon button in the left drawer header.
2026-01-09 17:30:53 +00:00
Shantur Rathore
1a7aefcbae feat(ui): session nav follows visible list
Cmd+Shift+[ and Cmd+Shift+] now cycle through visible sessions only (parents + expanded children) and no longer include Instance Info. Sidebar session list auto-scrolls to keep the active session row in view.
2026-01-09 16:34:44 +00:00
Shantur Rathore
e50d9f461a feat(ui): thread sessions in sidebar list
Show sessions as parent/child threads with expand/collapse and improved agent row styling. Keep a 5-session cache to avoid refetching messages when switching between recently visited sessions.
2026-01-09 16:02:53 +00:00
Shantur Rathore
d76cf8a3f7 Merge pull request #56 from bizzkoot/feat/centralized-permission-notifications
feat: Add Centralized Permission Notification System
2026-01-08 23:19:29 +00:00
bizzkoot
c7370fe7bc fix(ui): resolve multi-line border issue in permission notification cards
- Removed box-shadow from active permission items to eliminate double-border effect
- Added CSS rule to remove borders from nested ToolCall components within permission items
- Ensures consistent single-border styling aligned with existing design system
2026-01-09 06:02:58 +08:00
Shantur Rathore
3dfbe2a5b2 docs: add Linux NVIDIA Wayland workaround for Tauri AppImage 2026-01-08 20:43:11 +00:00
Shantur Rathore
e30c8b0253 fix(ui): auto-close permission center when queue empty 2026-01-08 20:25:54 +00:00
Shantur Rathore
df9fc529f9 feat(ui): rework permission center to reuse tool call view 2026-01-08 20:15:09 +00:00
Shantur Rathore
2e9f5b916c Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications 2026-01-08 20:09:42 +00:00
Shantur Rathore
fd464f349a ui: show permission-blocked tool calls in timeline 2026-01-08 19:16:39 +00:00
Shantur Rathore
ff6d6f4f76 Remove custom commands from palette 2026-01-08 17:52:55 +00:00
Shantur Rathore
cb2966fb08 Add slash command prompt support 2026-01-08 17:41:29 +00:00
bizzkoot
888e365d72 feat: enhance permission modal with tool details, queue nav, session nav, and responsive design
Modal Enhancements:
- Add accurate tool name extraction from message store (same method as inline chat)
- Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write')
- Add 'Go to Session ↗' button to navigate to originating session
- Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→)
- Add queue counter showing current position

Responsive Web Design:
- Portrait phones: 90vh max-height with safe margins (avoids browser URL bar)
- Landscape phones: 95vw with 50vh body scroll
- Tablets: adaptive layout
- Touch devices: 44-48px touch targets

Technical Changes:
- Import messageStoreBus for tool part lookup
- Query linked part.tool via permission messageId/callId
- Export setActivePermissionIdForInstance for queue navigation
- Add tool badge CSS styling
2026-01-08 06:38:59 +08:00
Shantur Rathore
e9241a1b93 Ensure child processes are stopped 2026-01-07 19:35:33 +00:00
bizzkoot
f01a06d85b feat: add centralized permission notification system for agent/subagent requests
Implements a unified permission notification UI that adapts to different runtime
environments (Electron desktop vs web browser) with distinct visual presentations.

## What Changed

### New Components
- `permission-notification-banner.tsx`: Adaptive notification component
  * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge
  * Web browser (portrait): Circular indicator badge showing pending count
- `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions
  * Displays permission type, detailed message, and diff viewer for file changes
  * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close)
  * Queue management with "X of Y" counter for multiple pending permissions
- `permission-notification.css`: Comprehensive styling with pulsing animations

### Integration
- Updated `instance-shell2.tsx`:
  * Added banner to desktop center toolbar (next to Command Palette)
  * Added banner to mobile/phone layout center section
  * Added modal component for permission approval workflow
- Updated `controls.css`: Imported new permission notification styles

## Why This Change

**Before**: Permission requests had no visual indicator in the UI, making it
difficult for users to know when agent/subagent actions required approval.

**After**: Users receive clear, persistent visual notifications with:
- Pulsing animation to draw attention
- Environment-appropriate UI (full banner on desktop, compact badge on web)
- Click-to-review workflow with full permission details

## Benefits

1. **Better UX**: Users immediately see when permissions need approval
2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts
3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management
4. **Queue Management**: Handles multiple pending permissions gracefully
5. **Contextual Information**: Shows diffs for file changes, permission types, etc.

## Impact

- **No Breaking Changes**: Purely additive feature
- **Build**:  Verified successful build
- **Testing**:  Tested in Electron app and web browser
2026-01-07 21:44:43 +08:00
Shantur Rathore
a68285da68 Merge pull request #54 from NeuralNomadsAI/dev
Release v0.5.1 - Build fixes
2026-01-07 09:48:46 +00:00
Shantur Rathore
c825ff066e bump version to 0.5.1 2026-01-07 08:56:19 +00:00
Shantur Rathore
f7ded37ea3 Fix macOS tauri cli package name 2026-01-07 06:35:59 +00:00
Shantur Rathore
847faf1214 Fix Tauri builds and Windows opencode-config loop 2026-01-07 06:25:19 +00:00
Shantur Rathore
b1691add1c Stabilize Tauri CLI install in CI 2026-01-07 06:10:30 +00:00
Shantur Rathore
3b9a44779a Exclude opencode-config from npm workspaces 2026-01-06 23:20:45 +00:00
Shantur Rathore
62fd88cd3f Install @tauri-apps/cli alongside platform bindings 2026-01-06 23:10:45 +00:00
Shantur Rathore
ce2273fe57 Sync package-lock for opencode-config workspace 2026-01-06 23:03:54 +00:00
Shantur Rathore
0eee325777 Stabilize Windows opencode-config install and pin tauri bindings 2026-01-06 22:57:38 +00:00
Shantur Rathore
f7c9db44ad Fix Windows builds by tracking opencode-config package.json 2026-01-06 22:43:46 +00:00
Shantur Rathore
1fcf89b945 Isolate opencode-config install from workspace 2026-01-06 20:58:01 +00:00
Shantur Rathore
f5682ea246 Fix dev CI build tool resolution and Windows npm spawning 2026-01-06 20:45:40 +00:00
Shantur Rathore
fa308696b4 Allow callers to control workflow permissions 2026-01-06 20:32:29 +00:00
Shantur Rathore
ac8dfcc607 Improve opencode-config install on Windows 2026-01-06 20:30:40 +00:00
Shantur Rathore
ac04d5daf7 Run build-only CI on dev pushes 2026-01-06 20:30:37 +00:00
Shantur Rathore
7fe8fee295 Fix Tauri CLI native dependency installs 2026-01-06 20:30:33 +00:00
Shantur Rathore
31940f972f Merge pull request #53 from NeuralNomadsAI/dev
Release v0.5.0
2026-01-06 19:40:17 +00:00
Shantur Rathore
b4663fb250 Merge pull request #44 from NeuralNomadsAI/dev
Release 0.4.0
2025-12-15 16:46:29 +00:00
35 changed files with 1764 additions and 625 deletions

View File

@@ -4,21 +4,33 @@ on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
description: "Version to apply to workspace packages (release builds)"
required: false
default: ""
type: string
tag:
description: "Git tag to upload assets to"
required: true
description: "Git tag to upload assets to (release builds)"
required: false
default: ""
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
required: false
default: ""
type: string
upload:
description: "Upload built artifacts to the GitHub release"
required: false
default: true
type: boolean
set_versions:
description: "Run npm version to set workspace versions"
required: false
default: true
type: boolean
permissions:
id-token: write
contents: write
# Permissions are intentionally omitted here so callers can choose
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
@@ -41,10 +53,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
@@ -53,6 +66,7 @@ jobs:
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -79,11 +93,12 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
@@ -92,6 +107,7 @@ jobs:
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -128,6 +145,7 @@ jobs:
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -157,18 +175,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -180,6 +218,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -232,6 +291,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
shell: bash
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
shell: bash
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -287,6 +369,7 @@ jobs:
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,18 +412,38 @@ jobs:
librsvg2-dev
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -367,6 +470,7 @@ jobs:
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -429,7 +533,7 @@ jobs:
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
@@ -497,10 +601,11 @@ jobs:
sudo gem install --no-document fpm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -509,6 +614,7 @@ jobs:
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob

View File

@@ -1,16 +1,18 @@
name: Dev Release
name: Dev CI
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
id-token: write
contents: write
contents: read
jobs:
dev-release:
uses: ./.github/workflows/reusable-release.yml
dev-ci:
uses: ./.github/workflows/build-and-upload.yml
with:
version_suffix: -dev
dist_tag: dev
upload: false
set_versions: false
secrets: inherit

View File

@@ -76,6 +76,29 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
Try running with one of these environment variables:
```bash
# Most reliable workaround (can reduce rendering performance)
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
# Alternative for some Wayland setups
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
```
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
```bash
#!/bin/bash
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
```
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
## Architecture & Development
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -16,7 +16,10 @@
},
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
}
},
@@ -1092,25 +1095,6 @@
"node": ">= 8"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.1.tgz",
"integrity": "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.1.1",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/plugin/node_modules/zod": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
@@ -5268,10 +5252,6 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/opencode-config": {
"resolved": "packages/opencode-config",
"link": true
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"dev": true,
@@ -7409,7 +7389,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -7434,14 +7414,16 @@
"license": "MIT"
},
"packages/opencode-config": {
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"extraneous": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.1"
}
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -7476,14 +7458,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.5.0",
"version": "0.6.0",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,11 +1,14 @@
{
"name": "codenomad-workspace",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
},
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.5.0",
"version": "0.6.0",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import path, { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
const binPaths = [
join(nodeModulesPath, ".bin"),
join(workspaceNodeModulesPath, ".bin"),
]
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
env,
}
const child = spawn(command, args, spawnOptions)

View File

@@ -0,0 +1,8 @@
{
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.8"
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.5.0",
"version": "0.6.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -10,7 +10,9 @@ const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
@@ -19,27 +21,39 @@ if (!existsSync(sourceDir)) {
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const result = spawnSync(
npmCmd,
[
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
],
{ cwd: sourceDir, stdio: "inherit", env: { ...process.env, npm_config_workspaces: "false" } },
)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })

View File

@@ -11,6 +11,7 @@ const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const EXIT_WAIT_TIMEOUT_MS = 5000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
@@ -21,6 +22,7 @@ interface ManagerDeps {
}
interface RunningProcess {
id: string
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
@@ -61,9 +63,15 @@ export class BackgroundProcessManager {
const child = spawn("bash", ["-c", command], {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
})
child.on("exit", () => {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
@@ -91,7 +99,7 @@ export class BackgroundProcessManager {
})
})
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -128,7 +136,7 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -149,7 +157,7 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -255,26 +263,64 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.child.kill("SIGTERM")
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
const pid = child.pid
if (!pid) return
if (process.platform !== "win32") {
try {
process.kill(-pid, signal)
return
} catch {
// Fall back to killing the direct child.
}
}
try {
child.kill(signal)
} catch {
// ignore
}
}
private async waitForExit(running: RunningProcess) {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
running.child.kill("SIGKILL")
let exited = false
const exitPromise = running.exitPromise.finally(() => {
exited = true
})
const killTimeout = setTimeout(() => {
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
}
}, STOP_TIMEOUT_MS)
await running.exitPromise.finally(() => {
resolved = true
clearTimeout(timeout)
})
try {
await Promise.race([
exitPromise,
new Promise<void>((resolve) => {
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
}),
])
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
this.running.delete(running.id)
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
}
} finally {
clearTimeout(killTimeout)
}
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"

View File

@@ -1,15 +1,15 @@
{
"name": "@codenomad/tauri-app",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
@@ -27,8 +27,9 @@ const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
@@ -60,9 +77,12 @@ const AlertDialog: Component = () => {
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
open
@@ -98,27 +118,47 @@ const AlertDialog: Component = () => {
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -26,21 +26,25 @@ import MenuIcon from "@suid/icons-material/Menu"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import type { BackgroundProcess } from "../../../../server/src/api-types"
import type { Session } from "../../types/session"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessionInfo,
getSessionThreads,
sessions,
setActiveParentSession,
setActiveSession,
} from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
@@ -50,6 +54,8 @@ import InstanceServiceStatus from "../instance-service-status"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel"
@@ -86,7 +92,7 @@ const MAX_SESSION_SIDEBAR_WIDTH = 360
const RIGHT_DRAWER_WIDTH = 260
const MIN_RIGHT_DRAWER_WIDTH = 200
const MAX_RIGHT_DRAWER_WIDTH = 380
const SESSION_CACHE_LIMIT = 2
const SESSION_CACHE_LIMIT = 5
const APP_BAR_HEIGHT = 56
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
@@ -141,6 +147,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
])
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
@@ -266,6 +273,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
requestAnimationFrame(() => measureDrawerHost())
})
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
return sessions().get(props.instance.id) ?? new Map()
})
const sessionThreads = createMemo(() => getSessionThreads(props.instance.id))
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(props.instance.id)
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
@@ -373,9 +386,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
}
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const instancePaletteCommands = createMemo(() => props.paletteCommands())
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>
@@ -477,7 +488,26 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
const handleSessionSelect = (sessionId: string) => {
setActiveSession(props.instance.id, sessionId)
if (sessionId === "info") {
setActiveSession(props.instance.id, sessionId)
return
}
const session = allInstanceSessions().get(sessionId)
if (!session) return
if (session.parentId === null) {
setActiveParentSession(props.instance.id, sessionId)
return
}
const parentId = session.parentId
if (!parentId) return
batch(() => {
setActiveParentSession(props.instance.id, parentId)
setActiveSession(props.instance.id, sessionId)
})
}
@@ -522,23 +552,27 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
createEffect(() => {
const sessionsMap = activeSessions()
const parentId = parentSessionIdForInstance()
const instanceSessions = allInstanceSessions()
const activeId = activeSessionIdForInstance()
setCachedSessionIds((current) => {
const next: string[] = []
const append = (id: string | null) => {
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
const touch = (id: string | null) => {
if (!id || id === "info") return
if (!sessionsMap.has(id)) return
if (next.includes(id)) return
next.push(id)
if (!instanceSessions.has(id)) return
const index = next.indexOf(id)
if (index !== -1) {
next.splice(index, 1)
}
next.unshift(id)
}
append(parentId)
append(activeId)
touch(activeId)
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
const trimmed = next.length > limit ? next.slice(0, limit) : next
const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
@@ -654,7 +688,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
const leftDrawerState = createMemo<DrawerViewState>(() => {
if (leftPinned()) return "pinned"
@@ -695,7 +729,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pinLeftDrawer = () => {
const pinLeftDrawer = () => {
blurIfInside(leftDrawerContentEl())
batch(() => {
setLeftPinned(true)
@@ -814,33 +848,37 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
>
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="flex items-center gap-2">
<IconButton
size="small"
color="inherit"
aria-label="Instance Info"
title="Instance Info"
onClick={() => handleSessionSelect("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
>
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instance.id}
sessions={activeSessions()}
sessions={allInstanceSessions()}
threads={sessionThreads()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}
onClose={(id) => {
const result = props.onCloseSession(id)
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to close session:", error))
}
}}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
@@ -1222,6 +1260,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</IconButton>
<div class="flex flex-wrap items-center gap-1 justify-center">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
@@ -1240,6 +1282,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<span class="status-dot" />
</span>
</div>
<IconButton
@@ -1268,46 +1312,52 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
}
>
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
onClick={handleLeftAppBarButtonClick}
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
disabled={leftDrawerState() === "pinned"}
>
{leftAppBarButtonIcon()}
</IconButton>
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
onClick={handleLeftAppBarButtonClick}
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
disabled={leftDrawerState() === "pinned"}
>
{leftAppBarButtonIcon()}
</IconButton>
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show>
</div>
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show>
</div>
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
style={{ flex: "0 0 auto", width: "auto" }}
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
style={{ flex: "0 0 auto", width: "auto" }}
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
<div class="session-toolbar-right flex items-center gap-3">
@@ -1429,6 +1479,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
process={selectedBackgroundProcess()}
onClose={closeBackgroundOutput}
/>
<PermissionApprovalModal
instanceId={props.instance.id}
isOpen={permissionModalOpen()}
onClose={() => setPermissionModalOpen(false)}
/>
</>
)
}

View File

@@ -5,7 +5,7 @@ import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -17,6 +17,7 @@ export interface TimelineSegment {
tooltip: string
shortLabel?: string
variant?: "auto" | "manual"
toolPartIds?: string[]
}
interface MessageTimelineProps {
@@ -47,6 +48,7 @@ interface PendingSegment {
toolTitles: string[]
toolTypeLabels: string[]
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean
}
@@ -179,6 +181,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
label,
tooltip,
shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
})
segmentIndex += 1
pending = null
@@ -187,7 +190,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) {
flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
}
return pending!
}
@@ -204,6 +207,9 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
target.toolTitles.push(getToolTitle(toolPart))
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id)
}
continue
}
@@ -359,9 +365,25 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
@@ -378,7 +400,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}

View File

@@ -0,0 +1,251 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { activePermissionId, getPermissionQueue } from "../stores/instances"
import { loadMessages, setActiveSession } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
interface PermissionApprovalModalProps {
instanceId: string
isOpen: boolean
onClose: () => void
}
type ResolvedToolCall = {
messageId: string
sessionId: string
toolPart: Extract<import("../types/message").ClientPart, { type: "tool" }>
messageVersion: number
partVersion: number
}
function resolveToolCallFromPermission(
instanceId: string,
permission: PermissionRequestLike,
): ResolvedToolCall | null {
const sessionId = getPermissionSessionId(permission)
const messageId = getPermissionMessageId(permission)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const metadata = ((permission as any).metadata || {}) as Record<string, unknown>
const directPartId =
(permission as any).partID ??
(permission as any).partId ??
(metadata as any).partID ??
(metadata as any).partId ??
undefined
const callId = getPermissionCallId(permission)
const findToolPart = (partId: string) => {
const partRecord = record.parts?.[partId]
const part = partRecord?.data
if (!part || part.type !== "tool") return null
return {
toolPart: part as ResolvedToolCall["toolPart"],
partVersion: partRecord.revision ?? 0,
}
}
if (typeof directPartId === "string" && directPartId.length > 0) {
const resolved = findToolPart(directPartId)
if (resolved) {
return {
messageId,
sessionId,
toolPart: resolved.toolPart,
messageVersion: record.revision,
partVersion: resolved.partVersion,
}
}
}
if (callId) {
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId === callId && typeof part.id === "string" && part.id.length > 0) {
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord.revision ?? 0,
}
}
}
}
return null
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const queue = createMemo(() => getPermissionQueue(props.instanceId))
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
const orderedQueue = createMemo(() => {
const current = queue()
const activeId = activePermId()
if (!activeId) return current
const index = current.findIndex((entry) => entry.id === activeId)
if (index <= 0) return current
const active = current[index]
if (!active) return current
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
})
const hasPermissions = createMemo(() => queue().length > 0)
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
}
}
createEffect(() => {
if (!props.isOpen) return
document.addEventListener("keydown", closeOnEscape)
onCleanup(() => document.removeEventListener("keydown", closeOnEscape))
})
createEffect(() => {
if (!props.isOpen) return
if (queue().length === 0) {
props.onClose()
}
})
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
async function handleLoadSession(sessionId: string) {
if (!sessionId) return
setLoadingSession(sessionId)
try {
await loadMessages(props.instanceId, sessionId)
} finally {
setLoadingSession((current) => (current === sessionId ? null : current))
}
}
function handleGoToSession(sessionId: string) {
if (!sessionId) return
setActiveSession(props.instanceId, sessionId)
props.onClose()
}
return (
<Show when={props.isOpen}>
<div class="permission-center-modal-backdrop" onClick={handleBackdropClick}>
<div class="permission-center-modal" role="dialog" aria-modal="true" aria-labelledby="permission-center-title">
<div class="permission-center-modal-header">
<div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title">
Permissions
</h2>
<Show when={queue().length > 0}>
<span class="permission-center-modal-count">{queue().length}</span>
</Show>
</div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
</button>
</div>
<div class="permission-center-modal-body">
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}>
<div class="permission-center-list" role="list">
<For each={orderedQueue()}>
{(permission) => {
const sessionId = getPermissionSessionId(permission) || ""
const isActive = () => permission.id === activePermId()
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
const showFallback = () => !resolved()
return (
<div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
role="listitem"
>
<div class="permission-center-item-header">
<div class="permission-center-item-heading">
<span class="permission-center-item-kind">{getPermissionKind(permission)}</span>
<Show when={isActive()}>
<span class="permission-center-item-chip">Active</span>
</Show>
</div>
<div class="permission-center-item-actions">
<button
type="button"
class="permission-center-item-action"
onClick={() => handleGoToSession(sessionId)}
>
Go to Session
</button>
<Show when={showFallback()}>
<button
type="button"
class="permission-center-item-action"
disabled={loadingSession() === sessionId}
onClick={() => handleLoadSession(sessionId)}
>
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
</button>
</Show>
</div>
</div>
<Show
when={resolved()}
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{getPermissionDisplayTitle(permission)}</code>
</div>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</div>
}
>
{(data) => (
<ToolCall
toolCall={data().toolPart}
toolCallId={data().toolPart.id}
messageId={data().messageId}
messageVersion={data().messageVersion}
partVersion={data().partVersion}
instanceId={props.instanceId}
sessionId={data().sessionId}
/>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</div>
</div>
</Show>
)
}
export default PermissionApprovalModal

View File

@@ -0,0 +1,36 @@
import { Show, createMemo, type Component } from "solid-js"
import { ShieldAlert } from "lucide-solid"
import { getPermissionQueueLength } from "../stores/instances"
interface PermissionNotificationBannerProps {
instanceId: string
onClick: () => void
}
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId))
const hasPermissions = createMemo(() => queueLength() > 0)
const label = createMemo(() => {
const count = queueLength()
return `${count} permission${count === 1 ? "" : "s"} pending approval`
})
return (
<Show when={hasPermissions()}>
<button
type="button"
class="permission-center-trigger"
onClick={props.onClick}
aria-label={label()}
title={label()}
>
<ShieldAlert class="permission-center-icon" aria-hidden="true" />
<span class="permission-center-count" aria-hidden="true">
{queueLength() > 9 ? "9+" : queueLength()}
</span>
</button>
</Show>
)
}
export default PermissionNotificationBanner

View File

@@ -7,9 +7,11 @@ import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -36,6 +38,7 @@ export default function PromptInput(props: PromptInputProps) {
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false)
const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention")
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [isDragging, setIsDragging] = createSignal(false)
@@ -560,14 +563,28 @@ export default function PromptInput(props: PromptInputProps) {
const currentAttachments = attachments()
if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell"
// Slash command routing (match OpenCode TUI): only run if the command exists.
const isSlashCandidate = !isShellMode && text.startsWith("/")
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
const commandToken = isSlashCandidate ? (firstSpace === -1 ? text : text.slice(0, firstSpace)) : ""
const commandName = isSlashCandidate ? commandToken.slice(1) : ""
const commandArgs = isSlashCandidate ? (firstSpace === -1 ? "" : text.slice(firstSpace + 1).trimStart()) : ""
const isKnownSlashCommand =
isSlashCandidate &&
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const refreshHistory = async () => {
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
await addToHistory(props.instanceFolder, historyEntry)
setHistory((prev) => {
const next = [resolvedPrompt, ...prev]
const next = [historyEntry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
@@ -580,12 +597,25 @@ export default function PromptInput(props: PromptInputProps) {
}
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
setPasteCount(0)
setImageCount(0)
// Ignore attachments for slash commands, but keep them for next prompt.
if (!isKnownSlashCommand) {
clearAttachments(props.instanceId, props.sessionId)
setPasteCount(0)
setImageCount(0)
setIgnoredAtPositions(new Set<number>())
} else {
syncAttachmentCounters("", currentAttachments)
setIgnoredAtPositions(new Set<number>())
}
setHistoryDraft(null)
if (isKnownSlashCommand) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
@@ -593,10 +623,14 @@ export default function PromptInput(props: PromptInputProps) {
} else {
await props.onSend(resolvedPrompt, [])
}
} else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
} else {
await props.onSend(resolvedPrompt, currentAttachments)
}
void refreshHistory()
if (!isKnownSlashCommand) {
void refreshHistory()
}
} catch (error) {
log.error("Failed to send message:", error)
showAlertDialog("Failed to send message", {
@@ -677,11 +711,27 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null)
const cursorPos = target.selectionStart
// Slash command picker (only when editing the command token: "/<query>")
if (value.startsWith("/") && cursorPos >= 1) {
const firstWhitespaceIndex = value.slice(1).search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
if (cursorPos <= tokenEnd) {
setPickerMode("command")
setAtPosition(0)
setSearchQuery(value.substring(1, cursorPos))
setShowPicker(true)
return
}
}
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
const previousAtPosition = atPosition()
if (lastAtIndex === -1) {
setIgnoredAtPositions(new Set<number>())
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
@@ -698,6 +748,7 @@ export default function PromptInput(props: PromptInputProps) {
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setPickerMode("mention")
setAtPosition(lastAtIndex)
setSearchQuery(textAfterAt)
setShowPicker(true)
@@ -716,9 +767,30 @@ export default function PromptInput(props: PromptInputProps) {
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
},
}
| { type: "command"; command: SDKCommand },
) {
if (item.type === "agent") {
if (item.type === "command") {
const name = item.command.name
const currentPrompt = prompt()
const afterSlash = currentPrompt.slice(1)
const firstWhitespaceIndex = afterSlash.search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
const before = ""
const after = currentPrompt.substring(tokenEnd)
const newPrompt = before + `/${name} ` + after
setPrompt(newPrompt)
setTimeout(() => {
if (textareaRef) {
const newCursorPos = `/${name} `.length
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
textareaRef.focus()
}
}, 0)
} else if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some(
@@ -822,7 +894,7 @@ export default function PromptInput(props: PromptInputProps) {
function handlePickerClose() {
const pos = atPosition()
if (pos !== null) {
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowPicker(false)
@@ -958,7 +1030,8 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
const commandHint = () => ({ key: "/", text: "Commands" })
const shouldShowOverlay = () => prompt().length === 0
@@ -981,9 +1054,11 @@ export default function PromptInput(props: PromptInputProps) {
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
@@ -1141,7 +1216,7 @@ export default function PromptInput(props: PromptInputProps) {
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
@@ -1149,6 +1224,11 @@ export default function PromptInput(props: PromptInputProps) {
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>

View File

@@ -1,14 +1,22 @@
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { Session, SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading, renameSession } from "../stores/sessions"
import {
deleteSession,
ensureSessionParentExpanded,
getVisibleSessionIds,
isSessionParentExpanded,
loading,
renameSession,
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
@@ -18,9 +26,9 @@ const log = getLogger("session")
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
threads: SessionThread[]
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
showHeader?: boolean
showFooter?: boolean
@@ -39,35 +47,23 @@ function formatSessionStatus(status: SessionStatus): string {
}
}
function arraysEqual(prev: readonly string[] | undefined, next: readonly string[]): boolean {
if (!prev) {
return false
}
if (prev.length !== next.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== next[i]) {
return false
}
}
return true
}
const SessionList: Component<SessionListProps> = (props) => {
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
const infoShortcut = keyboardRegistry.get("switch-to-info")
const isSessionDeleting = (sessionId: string) => {
const deleting = loading().deletingSession.get(props.instanceId)
return deleting ? deleting.has(sessionId) : false
}
const selectSession = (sessionId: string) => {
const session = props.sessions.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
}
props.onSelect(sessionId)
}
@@ -90,9 +86,45 @@ const SessionList: Component<SessionListProps> = (props) => {
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
if (isSessionDeleting(sessionId)) return
const shouldSelectFallback = props.activeSessionId === sessionId
let fallbackSessionId: string | undefined
if (shouldSelectFallback) {
const visible = getVisibleSessionIds(props.instanceId)
const currentIndex = visible.indexOf(sessionId)
const remaining = visible.filter((id) => id !== sessionId)
if (remaining.length > 0) {
if (currentIndex !== -1) {
for (let i = currentIndex; i < visible.length; i++) {
const candidate = visible[i]
if (candidate && candidate !== sessionId) {
fallbackSessionId = candidate
break
}
}
if (!fallbackSessionId) {
for (let i = currentIndex - 1; i >= 0; i--) {
const candidate = visible[i]
if (candidate && candidate !== sessionId) {
fallbackSessionId = candidate
break
}
}
}
}
fallbackSessionId ??= remaining[0]
}
}
try {
await deleteSession(props.instanceId, sessionId)
if (fallbackSessionId) {
setActiveSessionFromList(props.instanceId, fallbackSessionId)
}
} catch (error) {
log.error(`Failed to delete session ${sessionId}:`, error)
showToastNotification({ message: "Unable to delete session", variant: "error" })
@@ -127,7 +159,14 @@ const SessionList: Component<SessionListProps> = (props) => {
}
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
const SessionRow: Component<{
sessionId: string
isChild?: boolean
isLastChild?: boolean
hasChildren?: boolean
expanded?: boolean
onToggleExpand?: () => void
}> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId)
if (!session()) {
return <></>
@@ -144,41 +183,55 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
data-session-id={rowProps.sessionId}
onClick={() => selectSession(rowProps.sessionId)}
title={title()}
role="button"
aria-selected={isActive()}
aria-expanded={rowProps.hasChildren ? Boolean(rowProps.expanded) : undefined}
>
<div class="session-item-row session-item-header">
<div class="session-item-title-row">
<MessageSquare class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">{title()}</span>
{rowProps.isChild ? (
<Bot class="w-4 h-4 flex-shrink-0" />
) : (
<User class="w-4 h-4 flex-shrink-0" />
)}
<span class="session-item-title session-item-title--clamp">{title()}</span>
</div>
<Show when={rowProps.canClose}>
<span
class="session-item-close opacity-80 hover:opacity-100 hover:bg-status-error hover:text-white rounded p-0.5 transition-all"
onClick={(event) => {
event.stopPropagation()
props.onClose(rowProps.sessionId)
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</div>
<div class="session-item-row session-item-meta">
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{pendingPermission() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{statusText()}
</span>
<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" />
}
>
<span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
onClick={(event) => {
event.stopPropagation()
rowProps.onToggleExpand?.()
}}
role="button"
tabIndex={0}
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
title={rowProps.expanded ? "Collapse" : "Expand"}
>
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{pendingPermission() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{statusText()}
</span>
</div>
<div class="session-item-actions">
<span
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
@@ -234,38 +287,69 @@ const SessionList: Component<SessionListProps> = (props) => {
)
}
const userSessionIds = createMemo(
() => {
const ids: string[] = []
for (const session of props.sessions.values()) {
if (session.parentId === null) {
ids.push(session.id)
}
}
return ids
},
undefined,
{ equals: arraysEqual },
)
const childSessionIds = createMemo(
() => {
const children: { id: string; updated: number }[] = []
for (const session of props.sessions.values()) {
if (session.parentId !== null) {
children.push({ id: session.id, updated: session.time.updated ?? 0 })
}
}
if (children.length <= 1) {
return children.map((entry) => entry.id)
}
children.sort((a, b) => b.updated - a.updated)
return children.map((entry) => entry.id)
},
undefined,
{ equals: arraysEqual },
)
const activeParentId = createMemo(() => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null
const activeSession = props.sessions.get(activeId)
if (!activeSession) return null
return activeSession.parentId ?? activeSession.id
})
createEffect(() => {
const parentId = activeParentId()
if (!parentId) return
ensureSessionParentExpanded(props.instanceId, parentId)
})
const listEl = createSignal<HTMLElement | null>(null)
const escapeCss = (value: string) => {
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
return (CSS as any).escape(value)
}
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"")
}
const scrollActiveIntoView = (sessionId: string) => {
const root = listEl[0]()
if (!root) return
const selector = `[data-session-id="${escapeCss(sessionId)}"]`
const scrollNow = () => {
const target = root.querySelector(selector) as HTMLElement | null
if (!target) return
target.scrollIntoView({ block: "nearest", inline: "nearest" })
}
if (typeof requestAnimationFrame === "undefined") {
scrollNow()
return
}
// Wait a couple frames so expand/collapse DOM settles.
let raf1 = 0
let raf2 = 0
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
scrollNow()
})
})
onCleanup(() => {
if (raf1) cancelAnimationFrame(raf1)
if (raf2) cancelAnimationFrame(raf2)
})
}
createEffect(() => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return
scrollActiveIntoView(activeId)
})
return (
<div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
@@ -283,46 +367,34 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
</Show>
<div class="session-list flex-1 overflow-y-auto">
<div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
Instance
</div>
<div class="session-list-item group">
<button
class={`session-item-base ${props.activeSessionId === "info" ? "session-item-active" : "session-item-inactive"}`}
onClick={() => selectSession("info")}
title="Instance Info"
role="button"
aria-selected={props.activeSessionId === "info"}
>
<div class="session-item-row session-item-header">
<div class="session-item-title-row">
<Info class="w-4 h-4 flex-shrink-0" />
<span class="session-item-title truncate">Instance Info</span>
</div>
{infoShortcut && <Kbd shortcut={formatShortcut(infoShortcut)} class="ml-2 not-italic" />}
</div>
</button>
</div>
</div>
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<Show when={props.threads.length > 0}>
<div class="session-section">
<For each={props.threads}>
<Show when={userSessionIds().length > 0}>
<div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
User Session
</div>
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
</div>
</Show>
{(thread) => {
const expanded = () => 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={childSessionIds().length > 0}>
<div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
Agent Sessions
</div>
<For each={childSessionIds()}>{(id) => <SessionRow sessionId={id} />}</For>
<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>
</>
)
}}
</For>
</div>
</Show>
</div>

View File

@@ -1,5 +1,6 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import { Component, createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
@@ -67,13 +68,18 @@ function mapEntriesToFileItems(entries: { path: string; type: "file" | "director
})
}
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
type PickerItem =
| { type: "agent"; agent: Agent }
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
instanceClient: OpencodeClient | null
searchQuery: string
textareaRef?: HTMLTextAreaElement
@@ -81,6 +87,8 @@ interface UnifiedPickerProps {
}
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const mode = () => props.mode ?? "mention"
const [files, setFiles] = createSignal<FileItem[]>([])
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
@@ -246,6 +254,11 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return
}
if (mode() !== "mention") {
// Command mode doesn't use file snapshots.
return
}
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
@@ -262,6 +275,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
createEffect(() => {
if (!props.open) return
if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase()
const filtered = query
@@ -275,8 +289,25 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
setFilteredAgents(filtered)
})
const filteredCommands = createMemo(() => {
if (mode() !== "command") return []
const q = props.searchQuery.trim().toLowerCase()
const source = props.commands ?? []
if (!q) return source
return source.filter((cmd) => {
const nameMatch = cmd.name.toLowerCase().includes(q)
const descMatch = (cmd.description ?? "").toLowerCase().includes(q)
return nameMatch || descMatch
})
})
const allItems = (): PickerItem[] => {
const items: PickerItem[] = []
if (mode() === "command") {
filteredCommands().forEach((command) => items.push({ type: "command", command }))
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
files().forEach((file) => items.push({ type: "file", file }))
return items
@@ -329,9 +360,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
})
const commandCount = () => filteredCommands().length
const agentCount = () => filteredAgents().length
const fileCount = () => files().length
const isLoading = () => loadingState() !== "idle"
const isLoading = () => mode() === "mention" && loadingState() !== "idle"
const loadingMessage = () => {
if (loadingState() === "search") {
return "Searching..."
@@ -351,7 +383,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
>
<div class="dropdown-header">
<div class="dropdown-header-title">
Select Agent or File
<Show when={mode() === "command"} fallback={"Select Agent or File"}>
Select Command
</Show>
<Show when={isLoading()}>
<span class="ml-2">{loadingMessage()}</span>
</Show>
@@ -359,11 +393,41 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</div>
<div ref={scrollContainerRef} class="dropdown-content max-h-60">
<Show when={agentCount() === 0 && fileCount() === 0}>
<Show when={(mode() === "command" ? commandCount() === 0 : agentCount() === 0 && fileCount() === 0)}>
<div class="dropdown-empty">No results found</div>
</Show>
<Show when={agentCount() > 0}>
<Show when={mode() === "command" && commandCount() > 0}>
<div class="dropdown-section-header">COMMANDS</div>
<For each={filteredCommands()}>
{(command) => {
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name)
return (
<div
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "command", command })}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<div class="flex-1">
<div class="text-sm font-medium">/{command.name}</div>
<Show when={command.description}>
<div class="mt-0.5 text-xs" style="color: var(--text-muted)">
{(command.description ?? "").length > 80 ? (command.description ?? "").slice(0, 80) + "..." : command.description}
</div>
</Show>
</div>
</div>
</div>
)
}}
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<div class="dropdown-section-header">
AGENTS
</div>
@@ -418,7 +482,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={fileCount() > 0}>
<Show when={mode() === "mention" && fileCount() > 0}>
<div class="dropdown-section-header">
FILES
</div>

View File

@@ -1,6 +1,6 @@
import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog } from "../stores/alerts"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
import { getLogger } from "./logger"
@@ -11,15 +11,29 @@ export function commandRequiresArguments(template?: string): boolean {
return /\$(?:\d+|ARGUMENTS)/.test(template)
}
export function promptForCommandArguments(command: SDKCommand): string | null {
export async function promptForCommandArguments(command: SDKCommand): Promise<string | null> {
if (!commandRequiresArguments(command.template)) {
return ""
}
const input = window.prompt(`Arguments for /${command.name}`, "")
if (input === null) {
try {
return await showPromptDialog(`Arguments for /${command.name}`, {
title: "Custom command",
variant: "info",
inputLabel: "Arguments",
inputPlaceholder: "e.g. foo bar",
inputDefaultValue: "",
confirmLabel: "Run",
cancelLabel: "Cancel",
})
} catch (error) {
log.error("Failed to prompt for command arguments", error)
showAlertDialog("Failed to open arguments prompt.", {
title: "Command arguments",
variant: "error",
})
return null
}
return input
}
function formatCommandLabel(name: string): string {
@@ -43,11 +57,11 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
})
return
}
const args = promptForCommandArguments(cmd)
if (args === null) {
return
}
try {
const args = await promptForCommandArguments(cmd)
if (args === null) {
return
}
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
log.error("Failed to run custom command", error)

View File

@@ -4,13 +4,7 @@ import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
@@ -186,15 +180,16 @@ export function useCommands(options: UseCommandsOptions) {
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
const ids = getVisibleSessionIds(instanceId)
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) {
setActiveSession(instanceId, ids[next])
const currentActiveId = activeSessionIdForInstance() ?? ""
const currentIndex = ids.indexOf(currentActiveId)
const targetIndex = (currentIndex + 1 + ids.length) % ids.length
const targetSessionId = ids[targetIndex]
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},
@@ -210,15 +205,17 @@ export function useCommands(options: UseCommandsOptions) {
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
const ids = getVisibleSessionIds(instanceId)
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) {
setActiveSession(instanceId, ids[prev])
const currentActiveId = activeSessionIdForInstance() ?? ""
const currentIndex = ids.indexOf(currentActiveId)
const targetIndex =
currentIndex === -1 ? ids.length - 1 : currentIndex <= 0 ? ids.length - 1 : currentIndex - 1
const targetSessionId = ids[targetIndex]
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
}
},

View File

@@ -1,24 +1,11 @@
import { keyboardRegistry } from "../keyboard-registry"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessionId } from "../../stores/sessions"
import { activeSessionId, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
export function registerNavigationShortcuts() {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
const buildNavigationOrder = (instanceId: string): string[] => {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return []
const familySessions = getSessionFamily(instanceId, parentId)
if (familySessions.length === 0) return []
const [parentSession, ...childSessions] = familySessions
if (!parentSession) return []
const sortedChildren = childSessions.slice().sort((a, b) => b.time.updated - a.time.updated)
return [parentSession.id, "info", ...sortedChildren.map((session) => session.id)]
}
keyboardRegistry.register({
id: "instance-prev",
@@ -58,20 +45,23 @@ export function registerNavigationShortcuts() {
const instanceId = activeInstanceId()
if (!instanceId) return
const navigationIds = buildNavigationOrder(instanceId)
const navigationIds = getVisibleSessionIds(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
const currentActiveId = activeSessionId().get(instanceId) ?? ""
const currentIndex = navigationIds.indexOf(currentActiveId)
if (currentIndex === -1) {
currentIndex = navigationIds.length - 1
}
const targetIndex =
currentIndex === -1
? navigationIds.length - 1
: currentIndex <= 0
? navigationIds.length - 1
: currentIndex - 1
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
}
},
description: "previous session",
context: "global",
@@ -85,20 +75,17 @@ export function registerNavigationShortcuts() {
const instanceId = activeInstanceId()
if (!instanceId) return
const navigationIds = buildNavigationOrder(instanceId)
const navigationIds = getVisibleSessionIds(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
const currentActiveId = activeSessionId().get(instanceId) ?? ""
const currentIndex = navigationIds.indexOf(currentActiveId)
const targetIndex = (currentIndex + 1 + navigationIds.length) % navigationIds.length
if (currentIndex === -1) {
currentIndex = 0
}
const targetIndex = (currentIndex + 1) % navigationIds.length
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
if (targetSessionId) {
setActiveSessionFromList(instanceId, targetSessionId)
}
},
description: "next session",
context: "global",

View File

@@ -3,7 +3,7 @@ import { createSignal } from "solid-js"
export type AlertVariant = "info" | "warning" | "error"
export type AlertDialogState = {
type?: "alert" | "confirm"
type?: "alert" | "confirm" | "prompt"
title?: string
message: string
detail?: string
@@ -12,7 +12,17 @@ export type AlertDialogState = {
cancelLabel?: string
onConfirm?: () => void
onCancel?: () => void
// prompt-only
inputLabel?: string
inputPlaceholder?: string
inputDefaultValue?: string
// confirm-only
resolve?: (value: boolean) => void
// prompt-only
resolvePrompt?: (value: string | null) => void
}
const [alertDialogState, setAlertDialogState] = createSignal<AlertDialogState | null>(null)
@@ -39,6 +49,23 @@ export function showConfirmDialog(message: string, options?: Omit<AlertDialogSta
})
}
export function showPromptDialog(
message: string,
options?: Omit<AlertDialogState, "message" | "type" | "resolve" | "resolvePrompt">,
): Promise<string | null> {
const activeElement = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null
activeElement?.blur()
return new Promise<string | null>((resolvePrompt) => {
setAlertDialogState({
type: "prompt",
message,
...options,
resolvePrompt,
})
})
}
export function dismissAlertDialog() {
setAlertDialogState(null)
}

View File

@@ -562,6 +562,14 @@ function clearPermissionQueue(instanceId: string): void {
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
setActivePermissionId((prev) => {
const next = new Map(prev)
next.set(instanceId, permissionId)
return next
})
}
async function sendPermissionResponse(
instanceId: string,
sessionId: string,
@@ -656,6 +664,7 @@ export {
removePermissionFromQueue,
clearPermissionQueue,
sendPermissionResponse,
setActivePermissionIdForInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,

View File

@@ -1,4 +1,4 @@
import { createSignal } from "solid-js"
import { batch, createSignal } from "solid-js"
import type { Session, SessionStatus, Agent, Provider } from "../types/session"
import { deleteSession, loadMessages } from "./session-api"
@@ -23,6 +23,12 @@ 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())
@@ -40,6 +46,8 @@ const [loading, setLoading] = createSignal({
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
const [expandedSessionParents, setExpandedSessionParents] = createSignal<Map<string, Set<string>>>(new Map())
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
type InstanceIndicatorCounts = {
@@ -375,6 +383,140 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
return [parent, ...children]
}
function getSessionThreads(instanceId: string): SessionThread[] {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions || instanceSessions.size === 0) return []
const parents: Session[] = []
const childrenByParent = new Map<string, Session[]>()
for (const session of instanceSessions.values()) {
if (session.parentId === null) {
parents.push(session)
continue
}
const parentId = session.parentId
if (!parentId) continue
const children = childrenByParent.get(parentId)
if (children) {
children.push(session)
} else {
childrenByParent.set(parentId, [session])
}
}
const threads: SessionThread[] = []
for (const parent of parents) {
const children = childrenByParent.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)
threads.push({ parent, children, latestUpdated })
}
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
}
function isSessionParentExpanded(instanceId: string, parentSessionId: string): boolean {
return Boolean(expandedSessionParents().get(instanceId)?.has(parentSessionId))
}
function setSessionParentExpanded(instanceId: string, parentSessionId: string, expanded: boolean): void {
setExpandedSessionParents((prev) => {
const next = new Map(prev)
const currentSet = next.get(instanceId) ?? new Set<string>()
const updated = new Set(currentSet)
if (expanded) {
updated.add(parentSessionId)
} else {
updated.delete(parentSessionId)
}
if (updated.size === 0) {
next.delete(instanceId)
} else {
next.set(instanceId, updated)
}
return next
})
}
function toggleSessionParentExpanded(instanceId: string, parentSessionId: string): void {
setExpandedSessionParents((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)
} else {
updated.add(parentSessionId)
}
next.set(instanceId, updated)
return next
})
}
function ensureSessionParentExpanded(instanceId: string, parentSessionId: string): void {
if (isSessionParentExpanded(instanceId, parentSessionId)) return
setSessionParentExpanded(instanceId, parentSessionId, true)
}
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
}
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
batch(() => {
setActiveParentSession(instanceId, parentId)
setActiveSession(instanceId, sessionId)
})
}
function isSessionBusy(instanceId: string, sessionId: string): boolean {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return false
@@ -530,6 +672,13 @@ export {
getParentSessions,
getChildSessions,
getSessionFamily,
getSessionThreads,
getVisibleSessionIds,
isSessionParentExpanded,
setSessionParentExpanded,
toggleSessionParentExpanded,
ensureSessionParentExpanded,
setActiveSessionFromList,
isSessionBusy,
isSessionMessagesLoading,
getSessionInfo,

View File

@@ -9,6 +9,7 @@ import {
clearActiveParentSession,
clearInstanceDraftPrompts,
clearSessionDraftPrompt,
ensureSessionParentExpanded,
getActiveParentSession,
getActiveSession,
getChildSessions,
@@ -16,18 +17,24 @@ import {
getSessionDraftPrompt,
getSessionFamily,
getSessionInfo,
getSessionThreads,
getSessions,
getVisibleSessionIds,
isSessionBusy,
isSessionMessagesLoading,
isSessionParentExpanded,
loading,
providers,
sessionInfoByInstance,
sessions,
setActiveParentSession,
setActiveSession,
setActiveSessionFromList,
setSessionDraftPrompt,
setSessionParentExpanded,
setSessionStatus,
} from "./session-state"
toggleSessionParentExpanded,
} from "./session-state"
import { getDefaultModel } from "./session-models"
import {
@@ -85,6 +92,7 @@ export {
clearSessionDraftPrompt,
createSession,
deleteSession,
ensureSessionParentExpanded,
executeCustomCommand,
renameSession,
runShellCommand,
@@ -100,9 +108,12 @@ export {
getSessionDraftPrompt,
getSessionFamily,
getSessionInfo,
getSessionThreads,
getSessions,
getVisibleSessionIds,
isSessionBusy,
isSessionMessagesLoading,
isSessionParentExpanded,
loadMessages,
loading,
providers,
@@ -111,8 +122,11 @@ export {
sessions,
setActiveParentSession,
setActiveSession,
setActiveSessionFromList,
setSessionDraftPrompt,
setSessionParentExpanded,
setSessionStatus,
toggleSessionParentExpanded,
updateSessionAgent,
updateSessionModel,
}

View File

@@ -0,0 +1,237 @@
/* Central permission UI (toolbar + modal).
Kept intentionally small; reuse existing tokens. */
.permission-center-trigger {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
border: 1px solid var(--session-status-permission-fg);
background-color: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.permission-center-trigger:hover,
.permission-center-trigger:focus-visible {
outline: none;
background-color: color-mix(in srgb, var(--session-status-permission-bg) 70%, var(--surface-hover));
transform: translateY(-1px);
}
.permission-center-icon {
width: 1rem;
height: 1rem;
}
.permission-center-count {
line-height: 1;
}
.permission-center-modal-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--text-inverted) 55%, transparent);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: var(--space-lg);
}
.permission-center-modal {
width: min(900px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
border-radius: var(--radius-xl);
border: 1px solid var(--border-base);
background: var(--surface-base);
box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25));
overflow: hidden;
}
.permission-center-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md);
border-bottom: 1px solid var(--border-base);
}
.permission-center-modal-title-row {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.permission-center-modal-title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin: 0;
}
.permission-center-modal-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.4rem;
border-radius: 9999px;
background: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
border: 1px solid var(--session-status-permission-fg);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.permission-center-modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-base);
background: var(--surface-secondary);
color: var(--text-primary);
cursor: pointer;
}
.permission-center-modal-close:hover {
background: var(--surface-hover);
}
.permission-center-modal-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-md);
}
.permission-center-empty {
color: var(--text-secondary);
padding: var(--space-lg);
text-align: center;
}
.permission-center-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.permission-center-item {
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--surface-secondary);
overflow: hidden;
}
.permission-center-item-active {
border-color: var(--session-status-permission-fg);
}
.permission-center-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border-base);
background: var(--surface-base);
}
.permission-center-item-heading {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.permission-center-item-kind {
font-size: var(--font-size-xs);
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: var(--font-weight-semibold);
}
.permission-center-item-chip {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
padding: 0.1rem 0.4rem;
border-radius: 9999px;
border: 1px solid var(--session-status-permission-fg);
background: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
}
.permission-center-item-actions {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.permission-center-item-action {
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-base);
background: var(--surface-secondary);
color: var(--text-primary);
font-size: var(--font-size-xs);
cursor: pointer;
}
.permission-center-item-action:hover {
background: var(--surface-hover);
}
.permission-center-item-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.permission-center-fallback {
padding: var(--space-md);
}
.permission-center-fallback-title code {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--text-primary);
}
.permission-center-fallback-hint {
margin-top: var(--space-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
/* Remove border from tool-call when inside permission items to avoid double borders */
.permission-center-item .tool-call {
border: none;
border-radius: 0;
margin: 0;
}
@media (max-width: 720px) {
.permission-center-modal-backdrop {
padding: 0;
}
.permission-center-modal {
width: 100vw;
height: 100vh;
max-height: none;
border-radius: 0;
}
}

View File

@@ -6,3 +6,4 @@
@import "./components/env-vars.css";
@import "./components/directory-browser.css";
@import "./components/remote-access.css";
@import "./components/permission-notification.css";

View File

@@ -146,6 +146,30 @@
background-color: var(--surface-secondary);
}
.message-timeline-segment.message-timeline-segment-permission {
border-color: var(--session-status-permission-fg);
background-color: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
}
.message-timeline-segment.message-timeline-segment-permission:hover,
.message-timeline-segment.message-timeline-segment-permission:focus-visible {
background-color: var(--session-status-permission-bg);
color: var(--session-status-permission-fg);
border-color: var(--session-status-permission-fg);
transform: translateY(-1px);
}
.message-timeline-segment-active.message-timeline-segment-permission,
.message-timeline-segment-active.message-timeline-segment-permission:hover,
.message-timeline-segment-active.message-timeline-segment-permission:focus-visible {
background-color: var(--session-status-permission-bg) !important;
border-color: var(--session-status-permission-fg) !important;
color: var(--session-status-permission-fg) !important;
transform: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.message-timeline-compaction-auto {
border-color: var(--session-status-compacting-fg);
background-color: var(--surface-secondary);

View File

@@ -483,214 +483,3 @@
background-color: var(--surface-secondary);
}
/* Session view utility */
.session-view {
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
overflow: hidden;
}
/* Session list component */
.session-list-container {
@apply flex flex-col flex-1 min-h-0 relative;
background-color: var(--surface-secondary);
min-width: 200px;
max-width: 500px;
}
.session-sidebar {
@apply flex flex-col min-h-0;
background-color: var(--surface-secondary);
}
.session-sidebar-header {
@apply flex flex-col gap-2 w-full;
}
.session-sidebar-title {
color: var(--text-primary);
}
.session-sidebar-shortcuts {
@apply flex flex-col gap-1;
}
.session-sidebar-new {
@apply w-full;
}
.session-sidebar-controls {
@apply flex flex-col gap-3;
background-color: var(--surface-secondary);
}
.session-sidebar-controls > * {
@apply w-full;
}
.session-sidebar-separator {
background-color: var(--border-base);
height: 1px;
width: 100%;
}
.session-resize-handle {
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle--left {
right: 0;
}
.session-resize-handle--right {
left: 0;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 h-full w-2;
}
.session-resize-handle--left::before {
right: 0;
transform: translateX(50%);
}
.session-resize-handle--right::before {
left: 0;
transform: translateX(-50%);
}
.session-list-header {
@apply border-b relative;
border-color: var(--border-base);
}
.session-list-header h3 {
color: var(--text-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.session-list {
@apply flex-1;
}
.session-list-item {
@apply border-b last:border-b-0;
border-color: var(--border-base);
}
.session-item-base {
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
font-family: var(--font-family-sans);
font-size: var(--font-size-sm);
}
.session-item-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.session-item-row {
@apply flex items-center gap-2 w-full;
}
.session-item-header {
@apply justify-between;
}
.session-item-title-row {
@apply flex items-center gap-2 min-w-0 flex-1;
}
.session-item-meta {
@apply justify-between items-center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: 0.125rem;
}
.session-item-active .session-item-meta {
color: var(--text-secondary);
opacity: 1;
}
.session-item-actions {
@apply flex items-center gap-1;
}
.session-item-active {
background-color: var(--list-item-highlight-bg);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.session-item-inactive {
color: var(--text-secondary);
}
.session-item-inactive:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-active .session-item-close:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-title {
@apply flex-1 min-w-0;
font-weight: inherit;
}
.session-item-close {
@apply flex-shrink-0 p-0.5 rounded transition-all;
}
.session-item-close:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: inherit;
}
.session-list-footer {
@apply border-t;
border-color: var(--border-base);
}
.session-new-button {
background-color: var(--surface-base);
color: var(--text-primary);
border: 1px solid var(--border-base);
}
.session-new-button:hover {
background-color: var(--surface-hover);
}
.session-new-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
/* Responsive behavior for session list */
@media (max-width: 768px) {
.session-list-container {
min-width: 200px;
}
.session-item-base {
@apply px-2 py-2;
}
}

View File

@@ -206,6 +206,58 @@ session-sidebar-controls .selector-trigger-primary {
font-size: var(--font-size-sm);
}
.session-item-base.session-item-child {
padding-left: 2.25rem;
position: relative;
}
.session-item-base.session-item-child::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 1.125rem;
width: 1px;
background-color: var(--text-secondary);
opacity: 0.95;
pointer-events: none;
}
.session-item-base.session-item-child.session-item-child-last::before {
bottom: 50%;
}
.session-item-base.session-item-child::after {
content: "";
position: absolute;
top: 50%;
left: 1.125rem;
width: 0.875rem;
height: 1px;
background-color: var(--text-secondary);
opacity: 0.95;
transform: translateY(-0.5px);
pointer-events: none;
}
.session-item-base.session-item-border-user {
border-left: 4px solid var(--message-user-border);
}
.session-item-base.session-item-border-assistant {
border-left: 4px solid var(--message-assistant-border);
}
.session-item-expander {
@apply flex-shrink-0 p-0.5 rounded transition-colors;
}
.session-item-expander--spacer {
width: calc(0.875rem + 0.25rem);
height: calc(0.875rem + 0.25rem);
}
.session-item-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
@@ -221,7 +273,7 @@ session-sidebar-controls .selector-trigger-primary {
}
.session-item-title-row {
@apply flex items-center gap-2 min-w-0 flex-1;
@apply flex items-start gap-2 min-w-0 flex-1;
}
.session-item-meta {
@@ -247,6 +299,16 @@ session-sidebar-controls .selector-trigger-primary {
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.session-item-base.session-item-kind-user.session-item-active {
background-color: var(--session-user-active-bg);
}
.session-item-base.session-item-kind-assistant.session-item-active {
background-color: var(--session-assistant-active-bg);
}
.session-item-inactive {
color: var(--text-secondary);
}
@@ -256,6 +318,8 @@ session-sidebar-controls .selector-trigger-primary {
color: var(--text-primary);
}
.session-item-active .session-item-close:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
@@ -266,6 +330,14 @@ session-sidebar-controls .selector-trigger-primary {
font-weight: inherit;
}
.session-item-title--clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
word-break: break-word;
}
.session-item-close {
@apply flex-shrink-0 p-0.5 rounded transition-all;
}

View File

@@ -35,6 +35,10 @@
--message-tool-bg: #f8f9fa;
--message-tool-border: #6c757d;
/* Session list selection tints */
--session-user-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-user-border));
--session-assistant-active-bg: color-mix(in oklab, var(--surface-secondary) 85%, var(--message-assistant-border));
/* Semantic component colors */
--session-status-working-fg: #b45309;
--session-status-working-bg: rgba(245, 158, 11, 0.16);