restore: recover deleted documentation, CI/CD, and infrastructure files
Restored from origin/main (b4663fb): - .github/ workflows and issue templates - .gitignore (proper exclusions) - .opencode/agent/web_developer.md - AGENTS.md, BUILD.md, PROGRESS.md - dev-docs/ (9 architecture/implementation docs) - docs/screenshots/ (4 UI screenshots) - images/ (CodeNomad icons) - package-lock.json (dependency lockfile) - tasks/ (25+ project task files) Also restored original source files that were modified: - packages/ui/src/App.tsx - packages/ui/src/lib/logger.ts - packages/ui/src/stores/instances.ts - packages/server/src/server/routes/workspaces.ts - packages/server/src/workspaces/manager.ts - packages/server/src/workspaces/runtime.ts - packages/server/package.json Kept new additions: - Install-*.bat/.sh (enhanced installers) - Launch-*.bat/.sh (new launchers) - README.md (SEO optimized with GLM 4.7)
This commit is contained in:
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or regression in CodeNomad
|
||||
labels:
|
||||
- bug
|
||||
title: "[Bug]: "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: App Variant
|
||||
description: Which build are you running when this issue appears?
|
||||
multiple: false
|
||||
options:
|
||||
- Electron
|
||||
- Tauri
|
||||
- Server CLI
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Operating System & Version
|
||||
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
|
||||
placeholder: macOS 15.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: Issue Summary
|
||||
description: Briefly describe what is happening.
|
||||
placeholder: A quick one sentence problem statement
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: List the steps needed to reproduce the problem.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what you expected to happen instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs & Screenshots
|
||||
description: Attach relevant logs, stack traces, or screenshots if available.
|
||||
placeholder: Paste logs here or drag-and-drop files onto the issue.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
519
.github/workflows/build-and-upload.yml
vendored
Normal file
519
.github/workflows/build-and-upload.yml
vendored
Normal file
@@ -0,0 +1,519 @@
|
||||
name: Build and Upload Binaries
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to apply to workspace packages"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "Git tag to upload assets to"
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release name (unused here, for context)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS binaries (Electron)
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
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
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows binaries (Electron)
|
||||
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux binaries (Electron)
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos:
|
||||
runs-on: macos-15-intel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-x64 --no-save
|
||||
|
||||
- name: Build macOS bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (macOS)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-macos-arm64:
|
||||
runs-on: macos-26
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-darwin-arm64 --no-save
|
||||
|
||||
- name: Build macOS bundle (Tauri, arm64)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (macOS arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||
fi
|
||||
|
||||
- name: Upload Tauri release assets (macOS arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-windows:
|
||||
runs-on: windows-2025
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Set workspace versions
|
||||
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
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-win32-x64-msvc --no-save
|
||||
|
||||
- name: Build Windows bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||
$artifactDir = "packages/tauri-app/release-tauri"
|
||||
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
||||
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
||||
if ($null -ne $exe) {
|
||||
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||
}
|
||||
|
||||
- name: Upload Tauri release assets (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path "packages/tauri-app/release-tauri") {
|
||||
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
|
||||
Write-Host "Uploading $($_.FullName)"
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
}
|
||||
|
||||
build-tauri-linux:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libglib2.0-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux bundle (Tauri)
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Linux)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
|
||||
find_one() {
|
||||
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
|
||||
}
|
||||
|
||||
appimage=$(find_one "*.AppImage")
|
||||
deb=$(find_one "*.deb")
|
||||
rpm=$(find_one "*.rpm")
|
||||
|
||||
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
|
||||
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
|
||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||
|
||||
- name: Upload Tauri release assets (Linux)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
build-tauri-linux-arm64:
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/arm64
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust (Tauri)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Install Linux build dependencies (Tauri)
|
||||
run: |
|
||||
sudo dpkg --add-architecture arm64
|
||||
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
|
||||
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
|
||||
EOF
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
libgtk-3-dev:arm64 \
|
||||
libglib2.0-dev:arm64 \
|
||||
libwebkit2gtk-4.1-dev:arm64 \
|
||||
libsoup-3.0-dev:arm64 \
|
||||
libayatana-appindicator3-dev:arm64 \
|
||||
librsvg2-dev:arm64
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
|
||||
|
||||
- name: Build Linux bundle (Tauri arm64)
|
||||
env:
|
||||
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
|
||||
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
run: npm run build --workspace @codenomad/tauri-app
|
||||
|
||||
- name: Package Tauri artifacts (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEARCH_ROOT="packages/tauri-app/target"
|
||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
shopt -s nullglob globstar
|
||||
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
|
||||
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
|
||||
if [ -n "$first_artifact" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
|
||||
elif [ -f "$fallback_bin" ]; then
|
||||
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
|
||||
else
|
||||
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Tauri release assets (Linux arm64)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/tauri-app/release-tauri/*.zip; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
|
||||
|
||||
build-linux-rpm:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install rpm packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build Linux RPM binaries
|
||||
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload RPM release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.rpm; do
|
||||
[ -f "$file" ] || continue
|
||||
echo "Uploading $file"
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
16
.github/workflows/dev-release.yml
vendored
Normal file
16
.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Dev Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dev-release:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
version_suffix: -dev
|
||||
dist_tag: dev
|
||||
secrets: inherit
|
||||
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
74
.github/workflows/manual-npm-publish.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Manual NPM Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.2.0-dev)"
|
||||
required: false
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
dist_tag:
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm >=11.5.1
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Build server package (includes UI bundling)
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Set publish metadata
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_INPUT="${{ inputs.version }}"
|
||||
if [ -z "$VERSION_INPUT" ]; then
|
||||
VERSION_INPUT=$(node -p "require('./package.json').version")
|
||||
fi
|
||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump package version for publish
|
||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Publish server package with provenance
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||
run: |
|
||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
||||
17
.github/workflows/release.yml
vendored
Normal file
17
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Release Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: ./.github/workflows/reusable-release.yml
|
||||
with:
|
||||
dist_tag: latest
|
||||
secrets: inherit
|
||||
80
.github/workflows/reusable-release.yml
vendored
Normal file
80
.github/workflows/reusable-release.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Reusable Release
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_suffix:
|
||||
description: "Suffix appended to package.json version"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
dist_tag:
|
||||
description: "npm dist-tag to publish under"
|
||||
required: false
|
||||
default: dev
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute release versions
|
||||
id: versions
|
||||
env:
|
||||
VERSION_SUFFIX: ${{ inputs.version_suffix }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
|
||||
TAG="v${VERSION}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
- build-and-upload
|
||||
uses: ./.github/workflows/manual-npm-publish.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
dist_tag: ${{ inputs.dist_tag }}
|
||||
secrets: inherit
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite/
|
||||
.electron-vite/
|
||||
out/
|
||||
.dir-locals.el
|
||||
5
.opencode/agent/web_developer.md
Normal file
5
.opencode/agent/web_developer.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: Develops Web UI components.
|
||||
mode: all
|
||||
---
|
||||
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.
|
||||
20
AGENTS.md
Normal file
20
AGENTS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# AGENT NOTES
|
||||
|
||||
## Styling Guidelines
|
||||
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
|
||||
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
|
||||
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
|
||||
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
|
||||
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
|
||||
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
|
||||
|
||||
## Coding Principles
|
||||
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
|
||||
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
|
||||
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
|
||||
## Tooling Preferences
|
||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||
- Use the `write` tool only when creating new files from scratch.
|
||||
263
BUILD.md
Normal file
263
BUILD.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Building CodeNomad Binaries
|
||||
|
||||
This guide explains how to build distributable binaries for CodeNomad.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Bun** - Package manager and runtime
|
||||
- **Node.js** - For electron-builder
|
||||
- **Electron Builder** - Installed via devDependencies
|
||||
|
||||
## Quick Start
|
||||
|
||||
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
||||
|
||||
```bash
|
||||
npm run build --workspace @neuralnomads/codenomad-electron-app
|
||||
```
|
||||
|
||||
### Build for Current Platform (macOS default)
|
||||
|
||||
```bash
|
||||
bun run build:binaries
|
||||
```
|
||||
|
||||
This builds for macOS (Universal - Intel + Apple Silicon) by default.
|
||||
|
||||
## Platform-Specific Builds
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
# Universal (Intel + Apple Silicon) - Recommended
|
||||
bun run build:mac
|
||||
|
||||
# Intel only (x64)
|
||||
bun run build:mac-x64
|
||||
|
||||
# Apple Silicon only (ARM64)
|
||||
bun run build:mac-arm64
|
||||
```
|
||||
|
||||
**Output formats:** `.dmg`, `.zip`
|
||||
|
||||
### Windows
|
||||
|
||||
```bash
|
||||
# x64 (64-bit Intel/AMD)
|
||||
bun run build:win
|
||||
|
||||
# ARM64 (Windows on ARM)
|
||||
bun run build:win-arm64
|
||||
```
|
||||
|
||||
**Output formats:** `.exe` (NSIS installer), `.zip`
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# x64 (64-bit)
|
||||
bun run build:linux
|
||||
|
||||
# ARM64
|
||||
bun run build:linux-arm64
|
||||
```
|
||||
|
||||
**Output formats:** `.AppImage`, `.deb`, `.tar.gz`
|
||||
|
||||
### Build All Platforms
|
||||
|
||||
```bash
|
||||
bun run build:all
|
||||
```
|
||||
|
||||
⚠️ **Note:** Cross-platform builds may have limitations. Build on the target platform for best results.
|
||||
|
||||
## Build Process
|
||||
|
||||
The build script performs these steps:
|
||||
|
||||
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
|
||||
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
|
||||
3. **Package with electron-builder** → Platform-specific binaries
|
||||
|
||||
## Output
|
||||
|
||||
Binaries are generated in the `release/` directory:
|
||||
|
||||
```
|
||||
release/
|
||||
├── CodeNomad-0.1.0-mac-universal.dmg
|
||||
├── CodeNomad-0.1.0-mac-universal.zip
|
||||
├── CodeNomad-0.1.0-win-x64.exe
|
||||
├── CodeNomad-0.1.0-linux-x64.AppImage
|
||||
└── ...
|
||||
```
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
```
|
||||
CodeNomad-{version}-{os}-{arch}.{ext}
|
||||
```
|
||||
|
||||
- **version**: From package.json (e.g., `0.1.0`)
|
||||
- **os**: `mac`, `win`, `linux`
|
||||
- **arch**: `x64`, `arm64`, `universal`
|
||||
- **ext**: `dmg`, `zip`, `exe`, `AppImage`, `deb`, `tar.gz`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
### macOS
|
||||
|
||||
- **Build on:** macOS 10.13+
|
||||
- **Run on:** macOS 10.13+
|
||||
- **Code signing:** Optional (recommended for distribution)
|
||||
|
||||
### Windows
|
||||
|
||||
- **Build on:** Windows 10+, macOS, or Linux
|
||||
- **Run on:** Windows 10+
|
||||
- **Code signing:** Optional (recommended for distribution)
|
||||
|
||||
### Linux
|
||||
|
||||
- **Build on:** Any platform
|
||||
- **Run on:** Ubuntu 18.04+, Debian 10+, Fedora 32+, Arch
|
||||
- **Dependencies:** Varies by distro
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build fails on macOS
|
||||
|
||||
```bash
|
||||
# Install Xcode Command Line Tools
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
### Build fails on Linux
|
||||
|
||||
```bash
|
||||
# Install dependencies (Debian/Ubuntu)
|
||||
sudo apt-get install -y rpm
|
||||
|
||||
# Install dependencies (Fedora)
|
||||
sudo dnf install -y rpm-build
|
||||
```
|
||||
|
||||
### "electron-builder not found"
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
```
|
||||
|
||||
### Build is slow
|
||||
|
||||
- Use platform-specific builds instead of `build:all`
|
||||
- Close other applications to free up resources
|
||||
- Use SSD for faster I/O
|
||||
|
||||
## Development vs Production
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
bun run dev # Hot reload, no packaging
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```bash
|
||||
bun run build:binaries # Full build + packaging
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Example GitHub Actions workflow:
|
||||
|
||||
```yaml
|
||||
name: Build Binaries
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-mac:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun install
|
||||
- run: bun run build:mac
|
||||
|
||||
build-win:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun install
|
||||
- run: bun run build:win
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun install
|
||||
- run: bun run build:linux
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
Edit `package.json` → `build` section to customize:
|
||||
|
||||
- App icon
|
||||
- Code signing
|
||||
- Installer options
|
||||
- File associations
|
||||
- Auto-update settings
|
||||
|
||||
See [electron-builder docs](https://www.electron.build/) for details.
|
||||
|
||||
## Brand Assets
|
||||
|
||||
- `images/CodeNomad-Icon.png` — primary asset for in-app logo placements and the 1024×1024 master icon used to generate packaged app icons
|
||||
|
||||
To update the binaries:
|
||||
|
||||
1. Run `node scripts/generate-icons.js images/CodeNomad-Icon.png electron/resources` to round the corners and emit fresh `icon.icns`, `icon.ico`, and `icon.png` files.
|
||||
2. (Optional) Pass `--radius` to tweak the corner curvature or `--name` to change the filename prefix.
|
||||
3. If you prefer manual control, export `images/CodeNomad-Icon.png` with your tool of choice and place the generated files in `electron/resources/`.
|
||||
|
||||
## Clean Build
|
||||
|
||||
Remove previous builds:
|
||||
|
||||
```bash
|
||||
rm -rf release/ dist/
|
||||
bun run build:binaries
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I build for Windows on macOS?**
|
||||
A: Yes, but native binaries (e.g., DMG) require the target OS.
|
||||
|
||||
**Q: How large are the binaries?**
|
||||
A: Approximately 100-150 MB (includes Electron runtime).
|
||||
|
||||
**Q: Do I need code signing?**
|
||||
A: Not required, but recommended for public distribution to avoid security warnings.
|
||||
|
||||
**Q: How do I update the version?**
|
||||
A: Update `version` in `package.json`, then rebuild.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
- Check [electron-builder documentation](https://www.electron.build/)
|
||||
- Open an issue in the repository
|
||||
- Review existing build logs in `release/`
|
||||
149
PROGRESS.md
Normal file
149
PROGRESS.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# CodeNomad - Development Progress
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### Task 001: Project Setup ✅
|
||||
- Set up Electron + SolidJS + Vite + TypeScript
|
||||
- Configured TailwindCSS v3 (downgraded from v4 for electron-vite compatibility)
|
||||
- Build pipeline with electron-vite
|
||||
- Application window management
|
||||
- Application menu with keyboard shortcuts
|
||||
|
||||
### Task 002: Empty State UI & Folder Selection ✅
|
||||
- Empty state component with styled UI
|
||||
- Native folder picker integration
|
||||
- IPC handlers for folder selection
|
||||
- UI state management with SolidJS signals
|
||||
- Loading states with spinner
|
||||
- Keyboard shortcuts (Cmd/Ctrl+N)
|
||||
|
||||
### Task 003: Process Manager ✅
|
||||
- Process spawning: `opencode serve --port 0`
|
||||
- Port detection from stdout (regex: `opencode server listening on http://...`)
|
||||
- Process lifecycle management (spawn, kill, cleanup)
|
||||
- IPC communication for instance management
|
||||
- Instance state tracking (starting → ready → stopped/error)
|
||||
- Auto-cleanup on app quit
|
||||
- Error handling & timeout protection (10s)
|
||||
- Graceful shutdown (SIGTERM → SIGKILL)
|
||||
|
||||
### Task 004: SDK Integration ✅
|
||||
- Installed `@opencode-ai/sdk` package
|
||||
- SDK manager for client lifecycle
|
||||
- Session fetching from OpenCode server
|
||||
- Agent fetching (`client.app.agents()`)
|
||||
- Provider fetching (`client.config.providers()`)
|
||||
- Session store with SolidJS signals
|
||||
- Instance store updated with SDK client
|
||||
- Loading states for async operations
|
||||
- Error handling for network failures
|
||||
|
||||
### Task 005: Session Picker Modal ✅
|
||||
- Modal dialog with Kobalte Dialog
|
||||
- Lists ALL existing sessions (scrollable)
|
||||
- Session metadata display (title, relative timestamp)
|
||||
- Native HTML select dropdown for agents
|
||||
- Auto-selects first agent by default
|
||||
- Create new session with selected agent
|
||||
- Cancel button stops instance and closes modal
|
||||
- Resume session on click
|
||||
- Empty state for no sessions
|
||||
- Loading state for agents
|
||||
- Keyboard navigation (Escape to cancel)
|
||||
|
||||
## Current State
|
||||
|
||||
**Working Features:**
|
||||
- ✅ App launches with empty state
|
||||
- ✅ Folder selection via native dialog
|
||||
- ✅ OpenCode server spawning per folder
|
||||
- ✅ Port extraction and process tracking
|
||||
- ✅ SDK client connection to running servers
|
||||
- ✅ Session list fetching and display
|
||||
- ✅ Agent and provider data fetching
|
||||
- ✅ Session picker modal on instance creation
|
||||
- ✅ Resume existing sessions
|
||||
- ✅ Create new sessions with agent selection
|
||||
|
||||
**File Structure:**
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── electron/
|
||||
│ ├── main/
|
||||
│ │ ├── main.ts (window + IPC setup)
|
||||
│ │ ├── menu.ts (app menu)
|
||||
│ │ ├── ipc.ts (instance IPC handlers)
|
||||
│ │ └── process-manager.ts (server spawning)
|
||||
│ └── preload/
|
||||
│ └── index.ts (IPC bridge)
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── empty-state.tsx
|
||||
│ │ └── session-picker.tsx
|
||||
│ ├── lib/
|
||||
│ │ └── sdk-manager.ts
|
||||
│ ├── stores/
|
||||
│ │ ├── ui.ts
|
||||
│ │ ├── instances.ts
|
||||
│ │ └── sessions.ts
|
||||
│ ├── types/
|
||||
│ │ ├── electron.d.ts
|
||||
│ │ ├── instance.ts
|
||||
│ │ └── session.ts
|
||||
│ └── App.tsx
|
||||
├── tasks/
|
||||
│ ├── done/ (001-005)
|
||||
│ └── todo/ (006+)
|
||||
└── docs/
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Task 006: Message Stream UI (NEXT)
|
||||
- Message display component
|
||||
- User/assistant message rendering
|
||||
- Markdown support with syntax highlighting
|
||||
- Tool use visualization
|
||||
- Auto-scroll behavior
|
||||
|
||||
### Task 007: Prompt Input
|
||||
- Text input with multi-line support
|
||||
- Send button
|
||||
- File attachment support
|
||||
- Keyboard shortcuts (Enter for new line; Cmd+Enter/Ctrl+Enter to send)
|
||||
|
||||
### Task 008: Instance Tabs
|
||||
- Tab bar for multiple instances
|
||||
- Switch between instances
|
||||
- Close instance tabs
|
||||
- "+" button for new instance
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
cd packages/opencode-client
|
||||
bun run build
|
||||
bunx electron .
|
||||
```
|
||||
|
||||
**Known Issue:**
|
||||
- Dev mode (`bun dev`) fails due to Bun workspace hoisting + electron-vite
|
||||
- Workaround: Use production builds for testing
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Electron 38
|
||||
- SolidJS 1.8
|
||||
- TailwindCSS 3.x
|
||||
- @opencode-ai/sdk
|
||||
- @kobalte/core (Dialog)
|
||||
- Vite 5
|
||||
- TypeScript 5
|
||||
|
||||
## Stats
|
||||
|
||||
- **Tasks completed:** 5/5 (Phase 1)
|
||||
- **Files created:** 18+
|
||||
- **Lines of code:** ~1500+
|
||||
- **Build time:** ~7s
|
||||
- **Bundle size:** 152KB (renderer)
|
||||
180
dev-docs/INDEX.md
Normal file
180
dev-docs/INDEX.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Documentation Index
|
||||
|
||||
Quick reference to all documentation files.
|
||||
|
||||
## Main Documents
|
||||
|
||||
### [README.md](../README.md)
|
||||
|
||||
Project overview, installation, and getting started guide.
|
||||
|
||||
### [SUMMARY.md](SUMMARY.md)
|
||||
|
||||
Executive summary of the entire project - **start here!**
|
||||
|
||||
### [MVP-PRINCIPLES.md](MVP-PRINCIPLES.md)
|
||||
|
||||
**MVP development philosophy** - Focus on functionality, NOT performance ⚡
|
||||
|
||||
---
|
||||
|
||||
## Specification Documents
|
||||
|
||||
### [architecture.md](architecture.md)
|
||||
|
||||
**Complete system architecture**
|
||||
|
||||
- Component layers and responsibilities
|
||||
- State management structure
|
||||
- Data flow diagrams
|
||||
- Technology stack
|
||||
- Security and performance considerations
|
||||
|
||||
**Read this to understand:** How the app is structured
|
||||
|
||||
### [user-interface.md](user-interface.md)
|
||||
|
||||
**Complete UI/UX specifications**
|
||||
|
||||
- Every screen and component layout
|
||||
- Visual design specifications
|
||||
- Interaction patterns
|
||||
- Accessibility requirements
|
||||
- Color schemes and typography
|
||||
|
||||
**Read this to understand:** What the app looks like and how users interact
|
||||
|
||||
### [technical-implementation.md](technical-implementation.md)
|
||||
|
||||
**Implementation details**
|
||||
|
||||
- File structure
|
||||
- TypeScript interfaces
|
||||
- Process management logic
|
||||
- SDK integration patterns
|
||||
- IPC communication
|
||||
- Error handling strategies
|
||||
|
||||
**Read this to understand:** How to actually build it
|
||||
|
||||
### [build-roadmap.md](build-roadmap.md)
|
||||
|
||||
**Development plan**
|
||||
|
||||
- 8 phases of development
|
||||
- Task dependencies
|
||||
- Timeline estimates
|
||||
- Success criteria
|
||||
- Risk mitigation
|
||||
|
||||
**Read this to understand:** The development journey from start to finish
|
||||
|
||||
---
|
||||
|
||||
## Task Documents
|
||||
|
||||
### [tasks/README.md](../tasks/README.md)
|
||||
|
||||
**Task management guide**
|
||||
|
||||
- Task workflow
|
||||
- Naming conventions
|
||||
- How to work on tasks
|
||||
- Progress tracking
|
||||
|
||||
### Task Files (in tasks/todo/)
|
||||
|
||||
- **001-project-setup.md** - Electron + SolidJS boilerplate
|
||||
- **002-empty-state-ui.md** - Initial UI with folder selection
|
||||
- **003-process-manager.md** - OpenCode server spawning
|
||||
- **004-sdk-integration.md** - API client integration
|
||||
- **005-session-picker-modal.md** - Session selection UI
|
||||
|
||||
More tasks will be added as we progress through phases.
|
||||
|
||||
---
|
||||
|
||||
## Reading Order
|
||||
|
||||
### For First-Time Readers:
|
||||
|
||||
1. [SUMMARY.md](SUMMARY.md) - Get the big picture
|
||||
2. [architecture.md](architecture.md) - Understand the structure
|
||||
3. [user-interface.md](user-interface.md) - See what you're building
|
||||
4. [build-roadmap.md](build-roadmap.md) - Understand the plan
|
||||
5. [tasks/README.md](../tasks/README.md) - Learn the workflow
|
||||
|
||||
### For Implementers:
|
||||
|
||||
1. [tasks/README.md](../tasks/README.md) - Understand task workflow
|
||||
2. [technical-implementation.md](technical-implementation.md) - Implementation patterns
|
||||
3. [tasks/todo/001-\*.md](../tasks/todo/) - Start with first task
|
||||
4. Refer to architecture.md and user-interface.md as needed
|
||||
|
||||
### For Designers:
|
||||
|
||||
1. [user-interface.md](user-interface.md) - Complete UI specs
|
||||
2. [architecture.md](architecture.md) - Component structure
|
||||
3. [SUMMARY.md](SUMMARY.md) - Feature overview
|
||||
|
||||
### For Project Managers:
|
||||
|
||||
1. [SUMMARY.md](SUMMARY.md) - Executive overview
|
||||
2. [build-roadmap.md](build-roadmap.md) - Timeline and phases
|
||||
3. [tasks/README.md](../tasks/README.md) - Task tracking
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Questions
|
||||
|
||||
**Q: Where do I start?**
|
||||
A: Read [SUMMARY.md](SUMMARY.md), then start [Task 001](../tasks/todo/001-project-setup.md)
|
||||
|
||||
**Q: How long will this take?**
|
||||
A: See [build-roadmap.md](build-roadmap.md) - MVP in 3-7 weeks depending on commitment
|
||||
|
||||
**Q: What does the UI look like?**
|
||||
A: See [user-interface.md](user-interface.md) for complete specifications
|
||||
|
||||
**Q: How does it work internally?**
|
||||
A: See [architecture.md](architecture.md) for system design
|
||||
|
||||
**Q: How do I build feature X?**
|
||||
A: See [technical-implementation.md](technical-implementation.md) for patterns
|
||||
|
||||
**Q: What's the development plan?**
|
||||
A: See [build-roadmap.md](build-roadmap.md) for phases
|
||||
|
||||
---
|
||||
|
||||
## Document Status
|
||||
|
||||
| Document | Status | Last Updated |
|
||||
| --------------------------- | ----------- | ------------ |
|
||||
| README.md | ✅ Complete | 2024-10-22 |
|
||||
| SUMMARY.md | ✅ Complete | 2024-10-22 |
|
||||
| architecture.md | ✅ Complete | 2024-10-22 |
|
||||
| user-interface.md | ✅ Complete | 2024-10-22 |
|
||||
| technical-implementation.md | ✅ Complete | 2024-10-22 |
|
||||
| build-roadmap.md | ✅ Complete | 2024-10-22 |
|
||||
| tasks/README.md | ✅ Complete | 2024-10-22 |
|
||||
| Task 001-005 | ✅ Complete | 2024-10-22 |
|
||||
|
||||
**Project phase:** Post-MVP (Phases 1-3 complete; Phase 4 work underway).
|
||||
|
||||
---
|
||||
|
||||
## Contributing to Documentation
|
||||
|
||||
When updating documentation:
|
||||
|
||||
1. Update the relevant file
|
||||
2. Update "Last Updated" in this index
|
||||
3. Update SUMMARY.md if adding major changes
|
||||
4. Keep consistent formatting and style
|
||||
|
||||
---
|
||||
|
||||
_This index will be updated as more documentation is added._
|
||||
326
dev-docs/MVP-PRINCIPLES.md
Normal file
326
dev-docs/MVP-PRINCIPLES.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# MVP Development Principles
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
**Focus on functionality, NOT performance.**
|
||||
|
||||
The MVP (Minimum Viable Product) is about proving the concept and getting feedback. Performance optimization comes later, after we validate the product with real users.
|
||||
|
||||
---
|
||||
|
||||
## What We Care About in MVP
|
||||
|
||||
### ✅ DO Focus On:
|
||||
|
||||
1. **Functionality**
|
||||
- Does it work?
|
||||
- Can users complete their tasks?
|
||||
- Are all core features present?
|
||||
|
||||
2. **Correctness**
|
||||
- Does it produce correct results?
|
||||
- Does error handling work?
|
||||
- Is data persisted properly?
|
||||
|
||||
3. **User Experience**
|
||||
- Is the UI intuitive?
|
||||
- Are loading states clear?
|
||||
- Are error messages helpful?
|
||||
|
||||
4. **Stability**
|
||||
- Does it crash?
|
||||
- Can users recover from errors?
|
||||
- Does it lose data?
|
||||
|
||||
5. **Code Quality**
|
||||
- Is code readable?
|
||||
- Are types correct?
|
||||
- Is it maintainable?
|
||||
|
||||
### ❌ DON'T Focus On:
|
||||
|
||||
1. **Performance Optimization**
|
||||
- Virtual scrolling
|
||||
- Message batching
|
||||
- Lazy loading
|
||||
- Memory optimization
|
||||
- Render optimization
|
||||
|
||||
2. **Scalability**
|
||||
- Handling 1000+ messages
|
||||
- Multiple instances with 100+ sessions
|
||||
- Large file attachments
|
||||
- Massive search indexes
|
||||
|
||||
3. **Advanced Features**
|
||||
- Plugins
|
||||
- Advanced search
|
||||
- Custom themes
|
||||
- Workspace management
|
||||
|
||||
---
|
||||
|
||||
## Specific MVP Guidelines
|
||||
|
||||
### Messages & Rendering
|
||||
|
||||
**Simple approach:**
|
||||
|
||||
```typescript
|
||||
// Just render everything - no virtual scrolling
|
||||
<For each={messages()}>
|
||||
{(message) => <MessageItem message={message} />}
|
||||
</For>
|
||||
```
|
||||
|
||||
**Don't worry about:**
|
||||
|
||||
- Sessions with 500+ messages
|
||||
- Re-render performance
|
||||
- Memory usage
|
||||
- Scroll performance
|
||||
|
||||
**When to optimize:**
|
||||
|
||||
- Post-MVP (Phase 8)
|
||||
- Only if users report issues
|
||||
- Based on real-world usage data
|
||||
|
||||
### State Management
|
||||
|
||||
**Simple approach:**
|
||||
|
||||
- Use SolidJS signals directly
|
||||
- No batching
|
||||
- No debouncing
|
||||
- No caching layers
|
||||
|
||||
**Don't worry about:**
|
||||
|
||||
- Update frequency
|
||||
- Number of reactive dependencies
|
||||
- State structure optimization
|
||||
|
||||
### Process Management
|
||||
|
||||
**Simple approach:**
|
||||
|
||||
- Spawn servers as needed
|
||||
- Kill on close
|
||||
- Basic error handling
|
||||
|
||||
**Don't worry about:**
|
||||
|
||||
- Resource limits (max processes)
|
||||
- CPU/memory monitoring
|
||||
- Restart optimization
|
||||
- Process pooling
|
||||
|
||||
### API Communication
|
||||
|
||||
**Simple approach:**
|
||||
|
||||
- Direct SDK calls
|
||||
- Basic error handling
|
||||
- Simple retry (if at all)
|
||||
|
||||
**Don't worry about:**
|
||||
|
||||
- Request batching
|
||||
- Response caching
|
||||
- Optimistic updates
|
||||
- Request deduplication
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
When implementing any feature, ask:
|
||||
|
||||
### Is this optimization needed for MVP?
|
||||
|
||||
**NO if:**
|
||||
|
||||
- It only helps with large datasets
|
||||
- It only helps with many instances
|
||||
- It's about speed, not correctness
|
||||
- Users won't notice the difference
|
||||
- It adds significant complexity
|
||||
|
||||
**YES if:**
|
||||
|
||||
- Users can't complete basic tasks without it
|
||||
- App is completely unusable without it
|
||||
- It prevents data loss
|
||||
- It's a security requirement
|
||||
|
||||
### Examples
|
||||
|
||||
**Virtual Scrolling:** ❌ NO for MVP
|
||||
|
||||
- MVP users won't have 1000+ message sessions
|
||||
- Simple list rendering works fine for <100 messages
|
||||
- Add in Phase 8 if needed
|
||||
|
||||
**Error Handling:** ✅ YES for MVP
|
||||
|
||||
- Users need clear feedback when things fail
|
||||
- Prevents frustration and data loss
|
||||
- Core to usability
|
||||
|
||||
**Message Batching:** ❌ NO for MVP
|
||||
|
||||
- SolidJS handles updates efficiently
|
||||
- Only matters at very high frequency
|
||||
- Add later if users report lag
|
||||
|
||||
**Session Persistence:** ✅ YES for MVP
|
||||
|
||||
- Users expect sessions to persist
|
||||
- Losing work is unacceptable
|
||||
- Core functionality
|
||||
|
||||
---
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### MVP Testing Focus
|
||||
|
||||
**Test for:**
|
||||
|
||||
- ✅ Correctness (does it work?)
|
||||
- ✅ Error handling (does it fail gracefully?)
|
||||
- ✅ Data integrity (is data saved?)
|
||||
- ✅ User flows (can users complete tasks?)
|
||||
|
||||
**Don't test for:**
|
||||
|
||||
- ❌ Performance benchmarks
|
||||
- ❌ Load testing
|
||||
- ❌ Stress testing
|
||||
- ❌ Scalability limits
|
||||
|
||||
### Acceptable Performance
|
||||
|
||||
For MVP, these are **acceptable:**
|
||||
|
||||
- 100 messages render in 1 second
|
||||
- UI slightly laggy during heavy streaming
|
||||
- Memory usage grows with message count
|
||||
- Multiple instances slow down app
|
||||
|
||||
These become **unacceptable** only if:
|
||||
|
||||
- Users complain
|
||||
- App becomes unusable
|
||||
- Basic tasks can't be completed
|
||||
|
||||
---
|
||||
|
||||
## When to Optimize
|
||||
|
||||
### Post-MVP Triggers
|
||||
|
||||
Add optimization when:
|
||||
|
||||
1. **User Feedback**
|
||||
- Multiple users report slowness
|
||||
- Users abandon due to performance
|
||||
- Performance prevents usage
|
||||
|
||||
2. **Measurable Issues**
|
||||
- App freezes for >2 seconds
|
||||
- Memory usage causes crashes
|
||||
- UI becomes unresponsive
|
||||
|
||||
3. **Phase 8 Reached**
|
||||
- MVP complete and validated
|
||||
- User base established
|
||||
- Performance becomes focus
|
||||
|
||||
### How to Optimize
|
||||
|
||||
When the time comes:
|
||||
|
||||
1. **Measure First**
|
||||
- Profile actual bottlenecks
|
||||
- Use real user data
|
||||
- Identify specific problems
|
||||
|
||||
2. **Target Fixes**
|
||||
- Fix the specific bottleneck
|
||||
- Don't over-engineer
|
||||
- Measure improvement
|
||||
|
||||
3. **Iterate**
|
||||
- Optimize one thing at a time
|
||||
- Verify with users
|
||||
- Stop when "fast enough"
|
||||
|
||||
---
|
||||
|
||||
## Communication with Users
|
||||
|
||||
### During Alpha/Beta
|
||||
|
||||
**Be honest about performance:**
|
||||
|
||||
- "This is an MVP - expect some slowness with large sessions"
|
||||
- "We're focused on functionality first"
|
||||
- "Performance optimization is planned for v1.x"
|
||||
|
||||
**Set expectations:**
|
||||
|
||||
- Works best with <200 messages per session
|
||||
- Multiple instances may slow things down
|
||||
- We'll optimize based on your feedback
|
||||
|
||||
### Collecting Feedback
|
||||
|
||||
**Ask about:**
|
||||
|
||||
- ✅ What features are missing?
|
||||
- ✅ What's confusing?
|
||||
- ✅ What doesn't work?
|
||||
- ✅ Is it too slow to use?
|
||||
|
||||
**Don't ask about:**
|
||||
|
||||
- ❌ How many milliseconds for X?
|
||||
- ❌ Memory usage specifics
|
||||
- ❌ Benchmark comparisons
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### The MVP Mantra
|
||||
|
||||
> **Make it work, then make it better, then make it fast.**
|
||||
|
||||
For CodeNomad MVP:
|
||||
|
||||
- **Phase 1-7:** Make it work, make it better
|
||||
- **Phase 8+:** Make it fast
|
||||
|
||||
### Remember
|
||||
|
||||
- Premature optimization is the root of all evil
|
||||
- Real users provide better optimization guidance than assumptions
|
||||
- Functionality > Performance for MVP
|
||||
- You can't optimize what users don't use
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**When in doubt, ask:**
|
||||
|
||||
1. Is this feature essential for users to do their job? → Build it
|
||||
2. Is this optimization essential for the feature to work? → Build it
|
||||
3. Is this just making it faster/more efficient? → Defer to Phase 8
|
||||
|
||||
**MVP = Minimum _Viable_ Product**
|
||||
|
||||
- Viable = works and is useful
|
||||
- Viable ≠ optimized and fast
|
||||
348
dev-docs/SUMMARY.md
Normal file
348
dev-docs/SUMMARY.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# CodeNomad - Project Summary
|
||||
|
||||
## Current Status
|
||||
|
||||
We have completed the MVP milestones (Phases 1-3) and are now operating in post-MVP mode. Future work prioritizes multi-instance support, advanced input polish, and system integrations outlined in later phases.
|
||||
|
||||
## What We've Created
|
||||
|
||||
A comprehensive specification and task breakdown for building the CodeNomad desktop application.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── docs/ # Comprehensive documentation
|
||||
│ ├── architecture.md # System architecture & design
|
||||
│ ├── user-interface.md # UI/UX specifications
|
||||
│ ├── technical-implementation.md # Technical details & patterns
|
||||
│ ├── build-roadmap.md # Phased development plan
|
||||
│ └── SUMMARY.md # This file
|
||||
├── tasks/
|
||||
│ ├── README.md # Task management guide
|
||||
│ ├── todo/ # Tasks to implement
|
||||
│ │ ├── 001-project-setup.md
|
||||
│ │ ├── 002-empty-state-ui.md
|
||||
│ │ ├── 003-process-manager.md
|
||||
│ │ ├── 004-sdk-integration.md
|
||||
│ │ └── 005-session-picker-modal.md
|
||||
│ └── done/ # Completed tasks (empty)
|
||||
└── README.md # Project overview
|
||||
|
||||
```
|
||||
|
||||
## Documentation Overview
|
||||
|
||||
### 1. Architecture (architecture.md)
|
||||
|
||||
**What it covers:**
|
||||
|
||||
- High-level system design
|
||||
- Component layers (Main process, Renderer, Communication)
|
||||
- State management approach
|
||||
- Tab hierarchy (Instance tabs → Session tabs)
|
||||
- Data flow for key operations
|
||||
- Technology stack decisions
|
||||
- Security considerations
|
||||
|
||||
**Key sections:**
|
||||
|
||||
- Component architecture diagram
|
||||
- Instance/Session state structures
|
||||
- Communication patterns (HTTP, SSE)
|
||||
- Error handling strategies
|
||||
- Performance considerations
|
||||
|
||||
### 2. User Interface (user-interface.md)
|
||||
|
||||
**What it covers:**
|
||||
|
||||
- Complete UI layout specifications
|
||||
- Visual design for every component
|
||||
- Interaction patterns
|
||||
- Keyboard shortcuts
|
||||
- Accessibility requirements
|
||||
- Empty states and error states
|
||||
- Modal designs
|
||||
|
||||
**Key sections:**
|
||||
|
||||
- Detailed layout wireframes (ASCII art)
|
||||
- Component-by-component specifications
|
||||
- Message rendering formats
|
||||
- Control bar designs
|
||||
- Modal/overlay specifications
|
||||
- Color schemes and typography
|
||||
|
||||
### 3. Technical Implementation (technical-implementation.md)
|
||||
|
||||
**What it covers:**
|
||||
|
||||
- Technology stack details
|
||||
- Project file structure
|
||||
- State management patterns
|
||||
- Process management implementation
|
||||
- SDK integration approach
|
||||
- SSE event handling
|
||||
- IPC communication
|
||||
- Error handling strategies
|
||||
- Performance optimizations
|
||||
|
||||
**Key sections:**
|
||||
|
||||
- Complete project structure
|
||||
- TypeScript interfaces
|
||||
- Process spawning logic
|
||||
- SDK client management
|
||||
- Message rendering implementation
|
||||
- Build and packaging config
|
||||
|
||||
### 4. Build Roadmap (build-roadmap.md)
|
||||
|
||||
**What it covers:**
|
||||
|
||||
- 8 development phases
|
||||
- Task dependencies
|
||||
- Timeline estimates
|
||||
- Success criteria per phase
|
||||
- Risk mitigation
|
||||
- Release strategy
|
||||
|
||||
**Phases:**
|
||||
|
||||
1. **Foundation** (Week 1) - Project setup, process management
|
||||
2. **Core Chat** (Week 2) - Message display, SSE streaming
|
||||
3. **Essential Features** (Week 3) - Markdown, agents, errors
|
||||
4. **Multi-Instance** (Week 4) - Multiple projects support
|
||||
5. **Advanced Input** (Week 5) - Commands, file attachments
|
||||
6. **Polish** (Week 6) - UX refinements, settings
|
||||
7. **System Integration** (Week 7) - Native features
|
||||
8. **Advanced** (Week 8+) - Performance, plugins
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Current Tasks (Phase 1)
|
||||
|
||||
**001 - Project Setup** (2-3 hours)
|
||||
|
||||
- Set up Electron + SolidJS + Vite
|
||||
- Configure TypeScript, TailwindCSS
|
||||
- Create basic project structure
|
||||
- Verify build pipeline works
|
||||
|
||||
**002 - Empty State UI** (2-3 hours)
|
||||
|
||||
- Create empty state component
|
||||
- Implement folder selection dialog
|
||||
- Add keyboard shortcuts
|
||||
- Style and test responsiveness
|
||||
|
||||
**003 - Process Manager** (4-5 hours)
|
||||
|
||||
- Spawn OpenCode server processes
|
||||
- Parse stdout for port extraction
|
||||
- Kill processes on command
|
||||
- Handle errors and timeouts
|
||||
- Auto-cleanup on app quit
|
||||
|
||||
**004 - SDK Integration** (3-4 hours)
|
||||
|
||||
- Create SDK client per instance
|
||||
- Fetch sessions, agents, models
|
||||
- Implement session CRUD operations
|
||||
- Add error handling and retries
|
||||
|
||||
**005 - Session Picker Modal** (3-4 hours)
|
||||
|
||||
- Build modal with session list
|
||||
- Agent selector for new sessions
|
||||
- Keyboard navigation
|
||||
- Loading and error states
|
||||
|
||||
**Total Phase 1 time: ~15-20 hours (2-3 weeks part-time)**
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Two-Level Tabs
|
||||
|
||||
- **Level 1**: Instance tabs (one per project folder)
|
||||
- **Level 2**: Session tabs (multiple per instance)
|
||||
- Allows working on multiple projects with multiple conversations each
|
||||
|
||||
### 2. Process Management in Main Process
|
||||
|
||||
- Electron main process spawns servers
|
||||
- Parses stdout to get port
|
||||
- IPC sends port to renderer
|
||||
- Ensures clean shutdown on app quit
|
||||
|
||||
### 3. One SDK Client Per Instance
|
||||
|
||||
- Each instance has its own HTTP client
|
||||
- Connects to different port (different server)
|
||||
- Isolated state prevents cross-contamination
|
||||
|
||||
### 4. SolidJS for Reactivity
|
||||
|
||||
- Fine-grained reactivity for SSE updates
|
||||
- No re-render cascades
|
||||
- Better performance for real-time updates
|
||||
- Smaller bundle size than React
|
||||
|
||||
### 5. No Virtual Scrolling or Performance Optimization in MVP
|
||||
|
||||
- Start with simple list rendering
|
||||
- Don't optimize for large sessions initially
|
||||
- Focus on functionality, not performance
|
||||
- Add optimizations in post-MVP phases if needed
|
||||
- Reduces initial complexity and speeds up development
|
||||
|
||||
### 6. Messages and Tool Calls Inline
|
||||
|
||||
- All activity shows in main message stream
|
||||
- Tool calls expandable/collapsible
|
||||
- File changes visible inline
|
||||
- Single timeline view
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### For Each Task:
|
||||
|
||||
1. Read task file completely
|
||||
2. Review related documentation
|
||||
3. Follow steps in order
|
||||
4. Check off acceptance criteria
|
||||
5. Test thoroughly
|
||||
6. Move to done/ when complete
|
||||
|
||||
### Code Standards:
|
||||
|
||||
- TypeScript for everything
|
||||
- No `any` types
|
||||
- Descriptive variable names
|
||||
- Comments for complex logic
|
||||
- Error handling on all async operations
|
||||
- Loading states for all network calls
|
||||
|
||||
### Testing Approach:
|
||||
|
||||
- Manual testing at each step
|
||||
- Test on minimum window size (800x600)
|
||||
- Test error cases
|
||||
- Test edge cases (long text, special chars)
|
||||
- Keyboard navigation verification
|
||||
|
||||
## Next Steps
|
||||
|
||||
### To Start Building:
|
||||
|
||||
1. **Read all documentation**
|
||||
- Understand architecture
|
||||
- Review UI specifications
|
||||
- Study technical approach
|
||||
|
||||
2. **Start with Task 001**
|
||||
- Set up project structure
|
||||
- Install dependencies
|
||||
- Verify build works
|
||||
|
||||
3. **Follow sequential order**
|
||||
- Each task builds on previous
|
||||
- Don't skip ahead
|
||||
- Dependencies matter
|
||||
|
||||
4. **Track progress**
|
||||
- Update task checkboxes
|
||||
- Move completed tasks to done/
|
||||
- Update roadmap as you go
|
||||
|
||||
### When You Hit Issues:
|
||||
|
||||
1. Review task prerequisites
|
||||
2. Check documentation for clarification
|
||||
3. Look at related specs
|
||||
4. Ask questions on unclear requirements
|
||||
5. Document blockers and solutions
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### MVP (After Task 015)
|
||||
|
||||
- Can select folder → spawn server → chat
|
||||
- Messages stream in real-time
|
||||
- Can switch agents and models
|
||||
- Tool executions visible
|
||||
- Basic error handling works
|
||||
- **Performance is NOT a concern** - focus on functionality
|
||||
|
||||
### Beta (After Task 030)
|
||||
|
||||
- Multi-instance support
|
||||
- Advanced input (files, commands)
|
||||
- Polished UX
|
||||
- Settings and preferences
|
||||
- Native menus
|
||||
|
||||
### v1.0 (After Task 035)
|
||||
|
||||
- System tray integration
|
||||
- Auto-updates
|
||||
- Crash reporting
|
||||
- Production-ready stability
|
||||
|
||||
## Useful References
|
||||
|
||||
### Within This Project:
|
||||
|
||||
- `README.md` - Project overview and getting started
|
||||
- `docs/architecture.md` - System design
|
||||
- `docs/user-interface.md` - UI specifications
|
||||
- `docs/technical-implementation.md` - Implementation details
|
||||
- `tasks/README.md` - Task workflow guide
|
||||
|
||||
### External:
|
||||
|
||||
- OpenCode server API: https://opencode.ai/docs/server/
|
||||
- Electron docs: https://electronjs.org/docs
|
||||
- SolidJS docs: https://solidjs.com
|
||||
- Kobalte UI: https://kobalte.dev
|
||||
|
||||
## Questions to Resolve
|
||||
|
||||
Before starting implementation, clarify:
|
||||
|
||||
1. Exact OpenCode CLI syntax for spawning server
|
||||
2. Expected stdout format for port extraction
|
||||
3. SDK package location and version
|
||||
4. Any platform-specific gotchas
|
||||
5. Icon and branding assets location
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
**Conservative estimate (part-time, ~15 hours/week):**
|
||||
|
||||
- Phase 1 (MVP Foundation): 2-3 weeks
|
||||
- Phase 2 (Core Chat): 2 weeks
|
||||
- Phase 3 (Essential): 2 weeks
|
||||
- **MVP Complete: 6-7 weeks**
|
||||
|
||||
**Aggressive estimate (full-time, ~40 hours/week):**
|
||||
|
||||
- Phase 1: 1 week
|
||||
- Phase 2: 1 week
|
||||
- Phase 3: 1 week
|
||||
- **MVP Complete: 3 weeks**
|
||||
|
||||
Add 2-4 weeks for testing, bug fixes, and polish before alpha release.
|
||||
|
||||
## This is a Living Document
|
||||
|
||||
As you build:
|
||||
|
||||
- Update estimates based on actual time
|
||||
- Add new tasks as needed
|
||||
- Refine specifications
|
||||
- Document learnings
|
||||
- Track blockers and solutions
|
||||
|
||||
Good luck! 🚀
|
||||
228
dev-docs/TOOL_CALL_IMPLEMENTATION.md
Normal file
228
dev-docs/TOOL_CALL_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Tool Call Rendering Implementation
|
||||
|
||||
This document describes how tool calls are rendered in the CodeNomad, following the patterns established in the TUI.
|
||||
|
||||
## Overview
|
||||
|
||||
Each tool type has specialized rendering logic that displays the most relevant information for that tool. This matches the TUI's approach of providing context-specific displays rather than generic input/output dumps.
|
||||
|
||||
## Tool-Specific Rendering
|
||||
|
||||
### 1. **read** - File Reading
|
||||
|
||||
- **Title**: `Read {filename}`
|
||||
- **Body**: Preview of file content (first 6 lines) from `metadata.preview`
|
||||
- **Use case**: Shows what file content the assistant is reading
|
||||
|
||||
### 2. **edit** - File Editing
|
||||
|
||||
- **Title**: `Edit {filename}`
|
||||
- **Body**: Diff/patch showing changes from `metadata.diff`
|
||||
- **Special**: Shows diagnostics if available in metadata
|
||||
- **Use case**: Shows what changes are being made to files
|
||||
|
||||
### 3. **write** - File Writing
|
||||
|
||||
- **Title**: `Write {filename}`
|
||||
- **Body**: File content being written (first 10 lines)
|
||||
- **Special**: Shows diagnostics if available in metadata
|
||||
- **Use case**: Shows new file content being created
|
||||
|
||||
### 4. **bash** - Shell Commands
|
||||
|
||||
- **Title**: `Shell {description}` (or command if no description)
|
||||
- **Body**: Console-style display with `$ command` and output
|
||||
|
||||
```
|
||||
$ npm install vitest
|
||||
added 50 packages...
|
||||
```
|
||||
|
||||
- **Output from**: `metadata.output`
|
||||
- **Use case**: Shows command execution and results
|
||||
|
||||
### 5. **webfetch** - Web Fetching
|
||||
|
||||
- **Title**: `Fetch {url}`
|
||||
- **Body**: Fetched content (first 10 lines)
|
||||
- **Use case**: Shows web content being retrieved
|
||||
|
||||
### 6. **todowrite** - Task Planning
|
||||
|
||||
- **Title**: Dynamic based on todo phase:
|
||||
- All pending: "Creating plan"
|
||||
- All completed: "Completing plan"
|
||||
- Mixed: "Updating plan"
|
||||
- **Body**: Formatted todo list:
|
||||
- `- [x] Completed task`
|
||||
- `- [ ] Pending task`
|
||||
- `- [ ] ~~Cancelled task~~`
|
||||
- `- [ ] In progress task` (highlighted)
|
||||
- **Use case**: Shows the AI's task planning
|
||||
|
||||
### 7. **task** - Delegated Tasks
|
||||
|
||||
- **Title**: `Task[subagent_type] {description}`
|
||||
- **Body**: List of delegated tool calls with icons:
|
||||
|
||||
```
|
||||
⚡ bash: npm install
|
||||
📖 read package.json
|
||||
✏️ edit src/app.ts
|
||||
```
|
||||
|
||||
- **Special**: In TUI, includes navigation hints for session tree
|
||||
- **Use case**: Shows what the delegated agent is doing
|
||||
|
||||
### 8. **todoread** - Plan Reading
|
||||
|
||||
- **Special**: Hidden in TUI, returns empty string
|
||||
- **Use case**: Internal tool, not displayed to user
|
||||
|
||||
### 9. **glob** - File Pattern Matching
|
||||
|
||||
- **Title**: `Glob {pattern}`
|
||||
- **Use case**: Shows file search patterns
|
||||
|
||||
### 10. **grep** - Content Search
|
||||
|
||||
- **Title**: `Grep "{pattern}"`
|
||||
- **Use case**: Shows what content is being searched
|
||||
|
||||
### 11. **list** - Directory Listing
|
||||
|
||||
- **Title**: `List`
|
||||
- **Use case**: Shows directory operations
|
||||
|
||||
### 12. **patch** - Patching Files
|
||||
|
||||
- **Title**: `Patch`
|
||||
- **Use case**: Shows patch operations
|
||||
|
||||
### 13. **invalid** - Invalid Tool Calls
|
||||
|
||||
- **Title**: Name of the actual tool attempted
|
||||
- **Use case**: Shows validation errors
|
||||
|
||||
### 14. **Default** - Unknown Tools
|
||||
|
||||
- **Title**: Capitalized tool name
|
||||
- **Body**: Output truncated to 10 lines
|
||||
- **Use case**: Fallback for any new or custom tools
|
||||
|
||||
## Status States
|
||||
|
||||
### Pending
|
||||
|
||||
- **Icon**: ⏸ (pause symbol)
|
||||
- **Title**: Action text (e.g., "Writing command...", "Preparing edit...")
|
||||
- **Border**: Accent color
|
||||
- **Animation**: Shimmer effect on title
|
||||
- **Expandable**: Shows "Waiting for permission..." message
|
||||
|
||||
### Running
|
||||
|
||||
- **Icon**: ⏳ (hourglass)
|
||||
- **Title**: Same as completed state
|
||||
- **Border**: Warning color (yellow/orange)
|
||||
- **Animation**: Pulse on status icon
|
||||
|
||||
### Completed
|
||||
|
||||
- **Icon**: ✓ (checkmark)
|
||||
- **Title**: Tool-specific title with arguments
|
||||
- **Border**: Success color (green)
|
||||
- **Body**: Tool-specific rendered content
|
||||
|
||||
### Error
|
||||
|
||||
- **Icon**: ✗ (X mark)
|
||||
- **Title**: Same format but in error color
|
||||
- **Border**: Error color (red)
|
||||
- **Body**: Error message in highlighted box
|
||||
|
||||
## Title Rendering Logic
|
||||
|
||||
The title follows this pattern:
|
||||
|
||||
1. **Pending state**: Show action text
|
||||
|
||||
```
|
||||
"Writing command..."
|
||||
"Preparing edit..."
|
||||
"Delegating..."
|
||||
```
|
||||
|
||||
2. **Completed/Running/Error**: Show specific info
|
||||
|
||||
```
|
||||
"Shell npm install"
|
||||
"Edit src/app.ts"
|
||||
"Read package.json"
|
||||
"Task[general] Search for files"
|
||||
```
|
||||
|
||||
3. **Special cases**:
|
||||
- `todowrite`: Shows plan phase
|
||||
- `todoread`: Just "Plan"
|
||||
- `bash`: Uses description if available, otherwise shows command
|
||||
|
||||
## Metadata Usage
|
||||
|
||||
Tool calls use `metadata` for rich content:
|
||||
|
||||
- **read**: `metadata.preview` - file preview content
|
||||
- **edit**: `metadata.diff` - patch/diff text
|
||||
- **bash**: `metadata.output` - command output
|
||||
- **todowrite**: `metadata.todos[]` - todo items with status
|
||||
- **task**: `metadata.summary[]` - delegated tool calls
|
||||
- **edit/write**: `metadata.diagnostics` - LSP diagnostics
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Context-specific**: Each tool shows the most relevant information
|
||||
2. **Progressive disclosure**: Collapsed by default, expand for details
|
||||
3. **Visual hierarchy**: Icons, colors, and borders indicate status
|
||||
4. **Truncation**: Long content is truncated (6-10 lines) to prevent overwhelming
|
||||
5. **Consistency**: All tools follow same header/body/error structure
|
||||
|
||||
## Component Structure
|
||||
|
||||
```tsx
|
||||
<div class="tool-call tool-call-status-{status}">
|
||||
<button class="tool-call-header" onClick={toggle}>
|
||||
<span class="tool-call-icon">▶/▼</span>
|
||||
<span class="tool-call-emoji">{icon}</span>
|
||||
<span class="tool-call-summary">{title}</span>
|
||||
<span class="tool-call-status">{statusIcon}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div class="tool-call-details">
|
||||
{/* Tool-specific body content */}
|
||||
{error && <div class="tool-call-error-content">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS Classes
|
||||
|
||||
- `.tool-call` - Base container
|
||||
- `.tool-call-status-{pending|running|completed|error}` - Status-specific styling
|
||||
- `.tool-call-header` - Clickable header with expand/collapse
|
||||
- `.tool-call-emoji` - Tool type icon
|
||||
- `.tool-call-summary` - Tool title/description
|
||||
- `.tool-call-details` - Expanded content area
|
||||
- `.tool-call-content` - Code/output content (monospace)
|
||||
- `.tool-call-todos` - Todo list container
|
||||
- `.tool-call-task-summary` - Delegated task list
|
||||
- `.tool-call-error-content` - Error message display
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Syntax highlighting**: Use Shiki for code blocks in bash, read, write
|
||||
2. **Diff rendering**: Better diff visualization for edit tool
|
||||
3. **Copy buttons**: Quick copy for code/output
|
||||
4. **File links**: Click filename to open in editor
|
||||
5. **Diagnostics display**: Show LSP errors/warnings inline
|
||||
312
dev-docs/architecture.md
Normal file
312
dev-docs/architecture.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# CodeNomad Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
CodeNomad is a cross-platform desktop application built with Electron that provides a multi-instance, multi-session interface for interacting with OpenCode servers. Each instance manages its own OpenCode server process and can handle multiple concurrent sessions.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Electron Main Process │
|
||||
│ - Window management │
|
||||
│ - Process spawning (opencode serve) │
|
||||
│ - IPC bridge to renderer │
|
||||
│ - File system operations │
|
||||
└────────────────┬────────────────────────────────────────┘
|
||||
│ IPC
|
||||
┌────────────────┴────────────────────────────────────────┐
|
||||
│ Electron Renderer Process │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ SolidJS Application │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Instance Manager │ │ │
|
||||
│ │ │ - Spawns/kills OpenCode servers │ │ │
|
||||
│ │ │ - Manages SDK clients per instance │ │ │
|
||||
│ │ │ - Handles port allocation │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ State Management (SolidJS Stores) │ │ │
|
||||
│ │ │ - instances[] │ │ │
|
||||
│ │ │ - sessions[] per instance │ │ │
|
||||
│ │ │ - normalized message store per session │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ │ ┌────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageSection │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ HTTP/SSE
|
||||
┌────────────────┴────────────────────────────────────────┐
|
||||
│ Multiple OpenCode Server Processes │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Instance 1 │ │ Instance 2 │ │ Instance 3 │ │
|
||||
│ │ Port: 4096 │ │ Port: 4097 │ │ Port: 4098 │ │
|
||||
│ │ ~/project-a │ │ ~/project-a │ │ ~/api │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Layers
|
||||
|
||||
### 1. Main Process Layer (Electron)
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
- Create and manage application window
|
||||
- Spawn OpenCode server processes as child processes
|
||||
- Parse server stdout to extract port information
|
||||
- Handle process lifecycle (start, stop, restart)
|
||||
- Provide IPC handlers for renderer requests
|
||||
- Manage native OS integrations (file dialogs, menus)
|
||||
|
||||
**Key Modules:**
|
||||
|
||||
- `main.ts` - Application entry point
|
||||
- `process-manager.ts` - OpenCode server process spawning
|
||||
- `ipc-handlers.ts` - IPC communication handlers
|
||||
- `menu.ts` - Native application menu
|
||||
|
||||
### 2. Renderer Process Layer (SolidJS)
|
||||
|
||||
**Responsibilities:**
|
||||
|
||||
- Render UI components
|
||||
- Manage application state
|
||||
- Handle user interactions
|
||||
- Communicate with OpenCode servers via HTTP/SSE
|
||||
- Real-time message streaming
|
||||
|
||||
**Key Modules:**
|
||||
|
||||
- `App.tsx` - Root component
|
||||
- `stores/` - State management
|
||||
- `components/` - UI components
|
||||
- `contexts/` - SolidJS context providers
|
||||
- `lib/` - Utilities and helpers
|
||||
|
||||
### 3. Communication Layer
|
||||
|
||||
**HTTP API Communication:**
|
||||
|
||||
- SDK client per instance
|
||||
- RESTful API calls for session/config/file operations
|
||||
- Error handling and retries
|
||||
|
||||
**SSE (Server-Sent Events):**
|
||||
|
||||
- One EventSource per instance
|
||||
- Real-time message updates
|
||||
- Event type routing
|
||||
- Reconnection logic
|
||||
|
||||
**CLI Proxy Paths:**
|
||||
|
||||
- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance.
|
||||
- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces/<id>/instance`), which acts as the base URL for both REST and SSE calls.
|
||||
- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Instance Creation Flow
|
||||
|
||||
1. User selects folder via Electron file dialog
|
||||
2. Main process receives folder path via IPC
|
||||
3. Main process spawns `opencode serve --port 0`
|
||||
4. Main process parses stdout for port number
|
||||
5. Main process sends port + PID back to renderer
|
||||
6. Renderer creates SDK client for that port
|
||||
7. Renderer fetches initial session list
|
||||
8. Renderer displays session picker
|
||||
|
||||
### Message Streaming Flow
|
||||
|
||||
1. User submits prompt in active session
|
||||
2. Renderer POSTs to `/session/:id/message`
|
||||
3. SSE connection receives `MessageUpdated` events
|
||||
4. Events are routed to correct instance → session
|
||||
5. Message state updates trigger UI re-render
|
||||
6. Messages display with auto-scroll
|
||||
|
||||
### Child Session Creation Flow
|
||||
|
||||
1. OpenCode server creates child session
|
||||
2. SSE emits `SessionUpdated` event with `parentId`
|
||||
3. Renderer adds session to instance's session list
|
||||
4. New session tab appears automatically
|
||||
5. Optional: Auto-switch to new tab
|
||||
|
||||
## State Management
|
||||
|
||||
### Instance State
|
||||
|
||||
```
|
||||
instances: Map<instanceId, {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
proxyPath: string // `/workspaces/:id/instance`
|
||||
status: 'starting' | 'ready' | 'error' | 'stopped'
|
||||
client: OpenCodeClient
|
||||
eventSource: EventSource
|
||||
sessions: Map<sessionId, Session>
|
||||
activeSessionId: string | null
|
||||
logs: string[]
|
||||
}>
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
```
|
||||
Session: {
|
||||
id: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
messages: Message[]
|
||||
agent: string
|
||||
model: { providerId: string, modelId: string }
|
||||
status: 'idle' | 'streaming' | 'error'
|
||||
}
|
||||
```
|
||||
|
||||
### Message State
|
||||
|
||||
```
|
||||
Message: {
|
||||
id: string
|
||||
sessionId: string
|
||||
type: 'user' | 'assistant'
|
||||
parts: Part[]
|
||||
timestamp: number
|
||||
status: 'sending' | 'sent' | 'streaming' | 'complete' | 'error'
|
||||
}
|
||||
```
|
||||
|
||||
## Tab Hierarchy
|
||||
|
||||
### Level 1: Instance Tabs
|
||||
|
||||
Each tab represents one OpenCode server instance:
|
||||
|
||||
- Label: Folder name (with counter if duplicate)
|
||||
- Icon: Folder icon
|
||||
- Close button: Stops server and closes tab
|
||||
- "+" button: Opens folder picker for new instance
|
||||
|
||||
### Level 2: Session Tabs
|
||||
|
||||
Each instance has multiple session tabs:
|
||||
|
||||
- Main session tab (always present)
|
||||
- Child session tabs (auto-created)
|
||||
- Logs tab (shows server output)
|
||||
- "+" button: Creates new session
|
||||
|
||||
### Tab Behavior
|
||||
|
||||
**Instance Tab Switching:**
|
||||
|
||||
- Preserves session tabs
|
||||
- Switches active SDK client
|
||||
- Updates SSE event routing
|
||||
|
||||
**Session Tab Switching:**
|
||||
|
||||
- Loads messages for that session
|
||||
- Updates agent/model controls
|
||||
- Preserves scroll position
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core
|
||||
|
||||
- **Electron** - Desktop wrapper
|
||||
- **SolidJS** - Reactive UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool
|
||||
|
||||
### UI
|
||||
|
||||
- **TailwindCSS** - Styling
|
||||
- **Kobalte** - Accessible UI primitives
|
||||
- **Shiki** - Code syntax highlighting
|
||||
- **Marked** - Markdown parsing
|
||||
|
||||
### Communication
|
||||
|
||||
- **OpenCode SDK** - API client
|
||||
- **EventSource** - SSE streaming
|
||||
- **Node Child Process** - Process spawning
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Process Errors
|
||||
|
||||
- Server fails to start → Show error in instance tab
|
||||
- Server crashes → Attempt auto-restart once
|
||||
- Port already in use → Find next available port
|
||||
|
||||
### Network Errors
|
||||
|
||||
- API call fails → Show inline error, allow retry
|
||||
- SSE disconnects → Auto-reconnect with backoff
|
||||
- Timeout → Show timeout error, allow manual retry
|
||||
|
||||
### User Errors
|
||||
|
||||
- Invalid folder selection → Show error dialog
|
||||
- Permission denied → Show actionable error message
|
||||
- Out of memory → Graceful degradation message
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Note: Performance optimization is NOT a focus for MVP. These are future considerations.**
|
||||
|
||||
### Message Rendering (Post-MVP)
|
||||
|
||||
- Start with simple list rendering - no virtual scrolling
|
||||
- No message limits initially
|
||||
- Only optimize if users report issues
|
||||
- Virtual scrolling can be added in Phase 8 if needed
|
||||
|
||||
### State Updates
|
||||
|
||||
- SolidJS fine-grained reactivity handles most cases
|
||||
- No special optimizations needed for MVP
|
||||
- Batching/debouncing can be added later if needed
|
||||
|
||||
### Memory Management (Post-MVP)
|
||||
|
||||
- No memory management in MVP
|
||||
- Let browser/OS handle it
|
||||
- Add limits only if problems arise in testing
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No remote code execution
|
||||
- Server spawned with user permissions
|
||||
- No eval() or dangerous innerHTML
|
||||
- Sanitize markdown rendering
|
||||
- Validate all IPC messages
|
||||
- HTTPS only for external requests
|
||||
|
||||
## Extensibility Points
|
||||
|
||||
### Plugin System (Future)
|
||||
|
||||
- Custom slash commands
|
||||
- Custom message renderers
|
||||
- Theme extensions
|
||||
- Keybinding customization
|
||||
|
||||
### Configuration (Future)
|
||||
|
||||
- Per-instance settings
|
||||
- Global preferences
|
||||
- Workspace-specific configs
|
||||
- Import/export settings
|
||||
391
dev-docs/build-roadmap.md
Normal file
391
dev-docs/build-roadmap.md
Normal file
@@ -0,0 +1,391 @@
|
||||
# CodeNomad Build Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the phased approach to building the CodeNomad desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones.
|
||||
|
||||
**Status:** MVP (Phases 1-3) is complete. Focus now shifts to post-MVP phases starting with multi-instance support and advanced input refinements.
|
||||
|
||||
## MVP Scope (Phases 1-3)
|
||||
|
||||
The minimum viable product includes:
|
||||
|
||||
- Single instance management
|
||||
- Session selection and creation
|
||||
- Message display (streaming)
|
||||
- Basic prompt input (text only)
|
||||
- Agent/model selection
|
||||
- Process lifecycle management
|
||||
|
||||
**Target: 3-4 weeks for MVP**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Week 1)
|
||||
|
||||
**Goal:** Running Electron app that can spawn OpenCode servers
|
||||
|
||||
### Tasks
|
||||
|
||||
1. ✅ **001-project-setup** - Electron + SolidJS + Vite boilerplate
|
||||
2. ✅ **002-empty-state-ui** - Empty state UI with folder selection
|
||||
3. ✅ **003-process-manager** - Spawn and manage OpenCode server processes
|
||||
4. ✅ **004-sdk-integration** - Connect to server via SDK
|
||||
5. ✅ **005-session-picker-modal** - Select/create session modal
|
||||
|
||||
### Deliverables
|
||||
|
||||
- App launches successfully
|
||||
- Can select folder
|
||||
- Server spawns automatically
|
||||
- Session picker appears
|
||||
- Can create/select session
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can launch app → select folder → see session picker
|
||||
- Server process runs in background
|
||||
- Sessions fetch from API successfully
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Chat Interface (Week 2)
|
||||
|
||||
**Goal:** Display messages and send basic prompts
|
||||
|
||||
### Tasks
|
||||
|
||||
6. **006-instance-session-tabs** - Two-level tab navigation
|
||||
7. **007-message-display** - Render user and assistant messages
|
||||
8. **008-sse-integration** - Real-time message streaming
|
||||
9. **009-prompt-input-basic** - Text input with send functionality
|
||||
10. **010-tool-call-rendering** - Display tool executions inline
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Tab navigation works
|
||||
- Messages display correctly
|
||||
- Real-time updates via SSE
|
||||
- Can send text messages
|
||||
- Tool calls show status
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can type message → see response stream in real-time
|
||||
- Tool executions visible and expandable
|
||||
- Multiple sessions can be open simultaneously
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Essential Features (Week 3)
|
||||
|
||||
**Goal:** Feature parity with basic TUI functionality
|
||||
|
||||
### Tasks
|
||||
|
||||
11. **011-agent-model-selectors** - Dropdown for agent/model switching
|
||||
12. **012-markdown-rendering** - Proper markdown with code highlighting
|
||||
13. **013-logs-tab** - View server logs
|
||||
14. **014-error-handling** - Comprehensive error states and recovery
|
||||
15. **015-keyboard-shortcuts** - Essential keyboard navigation
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Can switch agents and models
|
||||
- Markdown renders beautifully
|
||||
- Code blocks have syntax highlighting
|
||||
- Server logs accessible
|
||||
- Errors handled gracefully
|
||||
- Cmd/Ctrl+N, K, L shortcuts work
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User experience matches TUI quality
|
||||
- All error cases handled
|
||||
- Keyboard-first navigation option available
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Multi-Instance Support (Week 4)
|
||||
|
||||
**Goal:** Work on multiple projects simultaneously
|
||||
|
||||
### Tasks
|
||||
|
||||
16. **016-instance-tabs** - Instance-level tab management
|
||||
17. **017-instance-state-persistence** - Remember instances across restarts
|
||||
18. **018-child-session-handling** - Auto-create tabs for child sessions
|
||||
19. **019-instance-lifecycle** - Stop, restart, reconnect instances
|
||||
20. **020-multiple-sdk-clients** - One SDK client per instance
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Multiple instance tabs
|
||||
- Persists across app restarts
|
||||
- Child sessions appear as new tabs
|
||||
- Can stop individual instances
|
||||
- All instances work independently
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- User can work on 3+ projects simultaneously
|
||||
- App remembers state on restart
|
||||
- No interference between instances
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Advanced Input (Week 5)
|
||||
|
||||
**Goal:** Full input capabilities matching TUI
|
||||
|
||||
### Tasks
|
||||
|
||||
21. **021-slash-commands** - Command palette with autocomplete
|
||||
22. **022-file-attachments** - @ mention file picker
|
||||
23. **023-drag-drop-files** - Drag files onto input
|
||||
24. **024-attachment-chips** - Display and manage attachments
|
||||
25. **025-input-history** - Up/down arrow message history
|
||||
|
||||
### Deliverables
|
||||
|
||||
- `/command` autocomplete works
|
||||
- `@file` picker searches files
|
||||
- Drag & drop attaches files
|
||||
- Attachment chips removable
|
||||
- Previous messages accessible
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Input feature parity with TUI
|
||||
- File context easy to add
|
||||
- Command discovery intuitive
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & UX (Week 6)
|
||||
|
||||
**Goal:** Production-ready user experience
|
||||
|
||||
### Tasks
|
||||
|
||||
26. **026-message-actions** - Copy, edit, regenerate messages
|
||||
27. **027-search-in-session** - Find text in conversation
|
||||
28. **028-session-management** - Rename, share, export sessions
|
||||
29. **029-settings-ui** - Preferences and configuration
|
||||
30. **030-native-menus** - Platform-native menu bar
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Message context menus
|
||||
- Search within conversation
|
||||
- Session CRUD operations
|
||||
- Settings dialog
|
||||
- Native File/Edit/View menus
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Feels polished and professional
|
||||
- All common actions accessible
|
||||
- Settings discoverable
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: System Integration (Week 7)
|
||||
|
||||
**Goal:** Native desktop app features
|
||||
|
||||
### Tasks
|
||||
|
||||
31. **031-system-tray** - Background running with tray icon
|
||||
32. **032-notifications** - Desktop notifications for events
|
||||
33. **033-auto-updater** - In-app update mechanism
|
||||
34. **034-crash-reporting** - Error reporting and recovery
|
||||
35. **035-performance-profiling** - Optimize rendering and memory
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Runs in background
|
||||
- Notifications for session activity
|
||||
- Auto-updates on launch
|
||||
- Crash logs captured
|
||||
- Smooth performance with large sessions
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- App feels native to platform
|
||||
- Updates seamlessly
|
||||
- Crashes don't lose data
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Advanced Features (Week 8+)
|
||||
|
||||
**Goal:** Beyond MVP, power user features
|
||||
|
||||
### Tasks
|
||||
|
||||
36. **036-virtual-scrolling** - Handle 1000+ message sessions
|
||||
37. **037-message-search-advanced** - Full-text search across sessions
|
||||
38. **038-workspace-management** - Save/load workspace configurations
|
||||
39. **039-theme-customization** - Custom themes and UI tweaks
|
||||
40. **040-plugin-system** - Extension API for custom tools
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Virtual scrolling for performance
|
||||
- Cross-session search
|
||||
- Workspace persistence
|
||||
- Theme editor
|
||||
- Plugin loader
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Handles massive sessions (5000+ messages)
|
||||
- Can search entire project history
|
||||
- Fully customizable
|
||||
|
||||
---
|
||||
|
||||
## Parallel Tracks
|
||||
|
||||
Some tasks can be worked on independently:
|
||||
|
||||
### Design Track
|
||||
|
||||
- Visual design refinements
|
||||
- Icon creation
|
||||
- Brand assets
|
||||
- Marketing materials
|
||||
|
||||
### Documentation Track
|
||||
|
||||
- User guide
|
||||
- Keyboard shortcuts reference
|
||||
- Troubleshooting docs
|
||||
- Video tutorials
|
||||
|
||||
### Infrastructure Track
|
||||
|
||||
- CI/CD pipeline
|
||||
- Automated testing
|
||||
- Release automation
|
||||
- Analytics integration
|
||||
|
||||
---
|
||||
|
||||
## Release Strategy
|
||||
|
||||
### Alpha (After Phase 3)
|
||||
|
||||
- Internal testing only
|
||||
- Frequent bugs expected
|
||||
- Rapid iteration
|
||||
|
||||
### Beta (After Phase 6)
|
||||
|
||||
- Public beta program
|
||||
- Feature complete
|
||||
- Bug fixes and polish
|
||||
|
||||
### v1.0 (After Phase 7)
|
||||
|
||||
- Public release
|
||||
- Stable and reliable
|
||||
- Production-ready
|
||||
|
||||
### v1.x (Phase 8+)
|
||||
|
||||
- Regular feature updates
|
||||
- Community-driven priorities
|
||||
- Plugin ecosystem
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### MVP Success
|
||||
|
||||
- 10 internal users daily
|
||||
- Can complete full coding session
|
||||
- <5 critical bugs
|
||||
|
||||
### Beta Success
|
||||
|
||||
- 100+ external users
|
||||
- NPS >50
|
||||
- <10 bugs per week
|
||||
|
||||
### v1.0 Success
|
||||
|
||||
- 1000+ users
|
||||
- <1% crash rate
|
||||
- Feature requests > bug reports
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Technical Risks
|
||||
|
||||
- **Process management complexity**
|
||||
- Mitigation: Extensive testing, graceful degradation
|
||||
- **SSE connection stability**
|
||||
- Mitigation: Robust reconnection logic, offline mode
|
||||
- **Performance with large sessions**
|
||||
- Mitigation: NOT a concern for MVP - defer to Phase 8
|
||||
- Accept slower performance initially, optimize later based on user feedback
|
||||
|
||||
### Product Risks
|
||||
|
||||
- **Feature creep**
|
||||
- Mitigation: Strict MVP scope, user feedback prioritization
|
||||
- **Over-optimization too early**
|
||||
- Mitigation: Focus on functionality first, optimize in Phase 8
|
||||
- Avoid premature performance optimization
|
||||
- **Platform inconsistencies**
|
||||
- Mitigation: Test on all platforms regularly
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External
|
||||
|
||||
- OpenCode CLI availability
|
||||
- OpenCode SDK stability
|
||||
- Electron framework updates
|
||||
|
||||
### Internal
|
||||
|
||||
- Design assets
|
||||
- Documentation
|
||||
- Testing resources
|
||||
|
||||
---
|
||||
|
||||
## Milestone Checklist
|
||||
|
||||
### Pre-Alpha
|
||||
|
||||
- [ ] All Phase 1 tasks complete
|
||||
- [ ] Can create instance and session
|
||||
- [ ] Internal demo successful
|
||||
|
||||
### Alpha
|
||||
|
||||
- [ ] All Phase 2-3 tasks complete
|
||||
- [ ] MVP feature complete
|
||||
- [ ] 5+ internal users testing
|
||||
|
||||
### Beta
|
||||
|
||||
- [ ] All Phase 4-6 tasks complete
|
||||
- [ ] Multi-instance stable
|
||||
- [ ] 50+ external testers
|
||||
|
||||
### v1.0
|
||||
|
||||
- [ ] All Phase 7 tasks complete
|
||||
- [ ] Documentation complete
|
||||
- [ ] <5 known bugs
|
||||
- [ ] Ready for public release
|
||||
82
dev-docs/solidjs-llms.txt
Normal file
82
dev-docs/solidjs-llms.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
# SolidJS Documentation
|
||||
|
||||
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
|
||||
|
||||
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
|
||||
|
||||
Key principles:
|
||||
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
|
||||
- Compile-time optimization: JSX transforms into efficient DOM operations
|
||||
- Unidirectional data flow: Props are read-only, promoting predictable state management
|
||||
- Component lifecycle: Components run once, with reactive primitives handling updates
|
||||
|
||||
**Use your web fetch tool on any of the following links to understand the relevant concept**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
|
||||
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
|
||||
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
|
||||
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
|
||||
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
|
||||
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
|
||||
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
|
||||
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
|
||||
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
|
||||
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
|
||||
|
||||
## Component APIs
|
||||
|
||||
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
|
||||
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
|
||||
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
|
||||
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
|
||||
|
||||
## Control Flow
|
||||
|
||||
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
|
||||
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
|
||||
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
|
||||
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
|
||||
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
|
||||
|
||||
## Derived Values
|
||||
|
||||
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
|
||||
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
|
||||
|
||||
## State Management
|
||||
|
||||
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
|
||||
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
|
||||
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
|
||||
|
||||
## Routing
|
||||
|
||||
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
|
||||
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
|
||||
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
|
||||
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
|
||||
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
|
||||
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
|
||||
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
|
||||
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
|
||||
|
||||
## Optional
|
||||
|
||||
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
|
||||
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
|
||||
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
|
||||
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options
|
||||
642
dev-docs/technical-implementation.md
Normal file
642
dev-docs/technical-implementation.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# Technical Implementation Details
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Technologies
|
||||
|
||||
- **Electron** v28+ - Desktop application wrapper
|
||||
- **SolidJS** v1.8+ - Reactive UI framework
|
||||
- **TypeScript** v5.3+ - Type-safe development
|
||||
- **Vite** v5+ - Fast build tool and dev server
|
||||
|
||||
### UI & Styling
|
||||
|
||||
- **TailwindCSS** v4+ - Utility-first styling
|
||||
- **Kobalte** - Accessible UI primitives for SolidJS
|
||||
- **Shiki** - Syntax highlighting for code blocks
|
||||
- **Marked** - Markdown parsing
|
||||
- **Lucide** - Icon library
|
||||
|
||||
### Communication
|
||||
|
||||
- **OpenCode SDK** (@opencode-ai/sdk) - API client
|
||||
- **EventSource API** - Server-sent events
|
||||
- **Node Child Process** - Process management
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **electron-vite** - Electron + Vite integration
|
||||
- **electron-builder** - Application packaging
|
||||
- **ESLint** - Code linting
|
||||
- **Prettier** - Code formatting
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── electron/
|
||||
│ ├── main/
|
||||
│ │ ├── main.ts # Electron main entry
|
||||
│ │ ├── window.ts # Window management
|
||||
│ │ ├── process-manager.ts # OpenCode server spawning
|
||||
│ │ ├── ipc.ts # IPC handlers
|
||||
│ │ └── menu.ts # Application menu
|
||||
│ ├── preload/
|
||||
│ │ └── index.ts # Preload script (IPC bridge)
|
||||
│ └── resources/
|
||||
│ └── icon.png # Application icon
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── instance-tabs.tsx # Level 1 tabs
|
||||
│ │ ├── session-tabs.tsx # Level 2 tabs
|
||||
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
|
||||
│ │ ├── message-item.tsx # Single message
|
||||
│ │ ├── tool-call.tsx # Tool execution display
|
||||
│ │ ├── prompt-input.tsx # Input with attachments
|
||||
│ │ ├── agent-selector.tsx # Agent dropdown
|
||||
│ │ ├── model-selector.tsx # Model dropdown
|
||||
│ │ ├── session-picker.tsx # Startup modal
|
||||
│ │ ├── logs-view.tsx # Server logs
|
||||
│ │ └── empty-state.tsx # No instances view
|
||||
│ ├── stores/
|
||||
│ │ ├── instances.ts # Instance state
|
||||
│ │ ├── sessions.ts # Session state per instance
|
||||
│ │ └── ui.ts # UI state (active tabs, etc)
|
||||
│ ├── lib/
|
||||
│ │ ├── sdk-manager.ts # SDK client management
|
||||
│ │ ├── sse-manager.ts # SSE connection handling
|
||||
│ │ ├── port-finder.ts # Find available ports
|
||||
│ │ └── markdown.ts # Markdown rendering utils
|
||||
│ ├── hooks/
|
||||
│ │ ├── use-instance.ts # Instance operations
|
||||
│ │ ├── use-session.ts # Session operations
|
||||
│ │ └── use-messages.ts # Message operations
|
||||
│ ├── types/
|
||||
│ │ ├── instance.ts # Instance types
|
||||
│ │ ├── session.ts # Session types
|
||||
│ │ └── message.ts # Message types
|
||||
│ ├── App.tsx # Root component
|
||||
│ ├── main.tsx # Renderer entry
|
||||
│ └── index.css # Global styles
|
||||
├── docs/ # Documentation
|
||||
├── tasks/ # Task tracking
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── electron.vite.config.ts
|
||||
├── tailwind.config.js
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Instance Store
|
||||
|
||||
```typescript
|
||||
interface InstanceState {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
|
||||
// Actions
|
||||
createInstance(folder: string): Promise<void>
|
||||
removeInstance(id: string): Promise<void>
|
||||
setActiveInstance(id: string): void
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: string // UUID
|
||||
folder: string // Absolute path
|
||||
port: number // Server port
|
||||
pid: number // Process ID
|
||||
status: InstanceStatus
|
||||
client: OpenCodeClient // SDK client
|
||||
eventSource: EventSource | null // SSE connection
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
type InstanceStatus =
|
||||
| "starting" // Server spawning
|
||||
| "ready" // Server connected
|
||||
| "error" // Failed to start
|
||||
| "stopped" // Server killed
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number
|
||||
level: "info" | "error" | "warn"
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
### Session Store
|
||||
|
||||
```typescript
|
||||
interface SessionState {
|
||||
// Per instance
|
||||
getSessions(instanceId: string): Session[]
|
||||
getActiveSession(instanceId: string): Session | null
|
||||
|
||||
// Actions
|
||||
createSession(instanceId: string, agent: string): Promise<Session>
|
||||
deleteSession(instanceId: string, sessionId: string): Promise<void>
|
||||
setActiveSession(instanceId: string, sessionId: string): void
|
||||
updateSession(instanceId: string, sessionId: string, updates: Partial<Session>): void
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
version: string
|
||||
time: { created: number; updated: number }
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Message content lives in the normalized message-v2 store
|
||||
// keyed by instanceId/sessionId/messageId
|
||||
|
||||
type SessionStatus =
|
||||
| "idle" // No activity
|
||||
| "streaming" // Assistant responding
|
||||
| "error" // Error occurred
|
||||
|
||||
```
|
||||
|
||||
### UI Store
|
||||
|
||||
```typescript
|
||||
interface UIState {
|
||||
// Tab state
|
||||
instanceTabOrder: string[]
|
||||
sessionTabOrder: Map<string, string[]> // instanceId -> sessionIds
|
||||
|
||||
// Modal state
|
||||
showSessionPicker: string | null // instanceId or null
|
||||
showSettings: boolean
|
||||
|
||||
// Actions
|
||||
reorderInstanceTabs(newOrder: string[]): void
|
||||
reorderSessionTabs(instanceId: string, newOrder: string[]): void
|
||||
openSessionPicker(instanceId: string): void
|
||||
closeSessionPicker(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Process Management
|
||||
|
||||
### Server Spawning
|
||||
|
||||
**Strategy:** Spawn with port 0 (random), parse stdout for actual port
|
||||
|
||||
```typescript
|
||||
interface ProcessManager {
|
||||
spawn(folder: string): Promise<ProcessInfo>
|
||||
kill(pid: number): Promise<void>
|
||||
restart(pid: number, folder: string): Promise<ProcessInfo>
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
stdout: Readable
|
||||
stderr: Readable
|
||||
}
|
||||
|
||||
// Implementation approach:
|
||||
// 1. Check if opencode binary exists
|
||||
// 2. Spawn: spawn('opencode', ['serve', '--port', '0'], { cwd: folder })
|
||||
// 3. Listen to stdout
|
||||
// 4. Parse line matching: "Server listening on port 4096"
|
||||
// 5. Resolve promise with port
|
||||
// 6. Timeout after 10 seconds
|
||||
```
|
||||
|
||||
### Port Parsing
|
||||
|
||||
```typescript
|
||||
// Expected output from opencode serve:
|
||||
// > Starting OpenCode server...
|
||||
// > Server listening on port 4096
|
||||
// > API available at http://localhost:4096
|
||||
|
||||
function parsePort(output: string): number | null {
|
||||
const match = output.match(/port (\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Server fails to start:**
|
||||
|
||||
- Parse stderr for error message
|
||||
- Display in instance tab with retry button
|
||||
- Common errors: Port in use, permission denied, binary not found
|
||||
|
||||
**Server crashes after start:**
|
||||
|
||||
- Detect via process 'exit' event
|
||||
- Attempt auto-restart once
|
||||
- If restart fails, show error state
|
||||
- Preserve session data for manual restart
|
||||
|
||||
## Communication Layer
|
||||
|
||||
### SDK Client Management
|
||||
|
||||
```typescript
|
||||
interface SDKManager {
|
||||
createClient(port: number): OpenCodeClient
|
||||
destroyClient(port: number): void
|
||||
getClient(port: number): OpenCodeClient | null
|
||||
}
|
||||
|
||||
// One client per instance
|
||||
// Client lifecycle tied to instance lifecycle
|
||||
```
|
||||
|
||||
### SSE Event Handling
|
||||
|
||||
```typescript
|
||||
interface SSEManager {
|
||||
connect(instanceId: string, port: number): void
|
||||
disconnect(instanceId: string): void
|
||||
|
||||
// Event routing
|
||||
onMessageUpdate(handler: (instanceId: string, event: MessageUpdateEvent) => void): void
|
||||
onSessionUpdate(handler: (instanceId: string, event: SessionUpdateEvent) => void): void
|
||||
onError(handler: (instanceId: string, error: Error) => void): void
|
||||
}
|
||||
|
||||
// Event flow:
|
||||
// 1. EventSource connects to /event endpoint
|
||||
// 2. Events arrive as JSON
|
||||
// 3. Route to correct instance store
|
||||
// 4. Update reactive state
|
||||
// 5. UI auto-updates via signals
|
||||
```
|
||||
|
||||
### Reconnection Logic
|
||||
|
||||
```typescript
|
||||
// SSE disconnects:
|
||||
// - Network issue
|
||||
// - Server restart
|
||||
// - Tab sleep (browser optimization)
|
||||
|
||||
class SSEConnection {
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 5
|
||||
private reconnectDelay = 1000 // Start with 1s
|
||||
|
||||
reconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.emitError(new Error("Max reconnection attempts reached"))
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect()
|
||||
this.reconnectAttempts++
|
||||
this.reconnectDelay *= 2 // Exponential backoff
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Message Rendering
|
||||
|
||||
### Markdown Processing
|
||||
|
||||
```typescript
|
||||
// Use Marked + Shiki for syntax highlighting
|
||||
import { marked } from "marked"
|
||||
import { markedHighlight } from "marked-highlight"
|
||||
import { getHighlighter } from "shiki"
|
||||
|
||||
const highlighter = await getHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: ["typescript", "javascript", "python", "bash", "json"],
|
||||
})
|
||||
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
highlight(code, lang) {
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang,
|
||||
theme: isDark ? "github-dark" : "github-light",
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Call Rendering
|
||||
|
||||
```typescript
|
||||
interface ToolCallComponent {
|
||||
tool: string // "bash", "edit", "read"
|
||||
input: any // Tool-specific input
|
||||
output?: any // Tool-specific output
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
expanded: boolean // Collapse state
|
||||
}
|
||||
|
||||
// Render logic:
|
||||
// - Default: Collapsed, show summary
|
||||
// - Click: Toggle expanded state
|
||||
// - Running: Show spinner
|
||||
// - Complete: Show checkmark
|
||||
// - Error: Show error icon + message
|
||||
```
|
||||
|
||||
### Streaming Updates
|
||||
|
||||
```typescript
|
||||
// Messages stream in via SSE
|
||||
// Update strategy: Replace existing message parts
|
||||
|
||||
function handleMessagePartUpdate(event: MessagePartEvent) {
|
||||
const session = getSession(event.sessionId)
|
||||
const message = session.messages.find((m) => m.id === event.messageId)
|
||||
|
||||
if (!message) {
|
||||
// New message
|
||||
session.messages.push(createMessage(event))
|
||||
} else {
|
||||
// Update existing
|
||||
const partIndex = message.parts.findIndex((p) => p.id === event.partId)
|
||||
if (partIndex === -1) {
|
||||
message.parts.push(event.part)
|
||||
} else {
|
||||
message.parts[partIndex] = event.part
|
||||
}
|
||||
}
|
||||
|
||||
// SolidJS reactivity triggers re-render
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**MVP Approach: Don't optimize prematurely**
|
||||
|
||||
### Message Rendering (MVP)
|
||||
|
||||
**Simple approach - no optimization:**
|
||||
|
||||
```typescript
|
||||
// Render all messages - no virtual scrolling, no limits
|
||||
<For each={messages()}>
|
||||
{(message) => <MessageItem message={message} />}
|
||||
</For>
|
||||
|
||||
// SolidJS will handle reactivity efficiently
|
||||
// Only optimize if users report issues
|
||||
```
|
||||
|
||||
### State Update Batching
|
||||
|
||||
**Not needed for MVP:**
|
||||
|
||||
- SolidJS reactivity is efficient enough
|
||||
- SSE updates will just trigger normal re-renders
|
||||
- Add batching only if performance issues arise
|
||||
|
||||
### Memory Management
|
||||
|
||||
**Not needed for MVP:**
|
||||
|
||||
- No message limits
|
||||
- No pruning
|
||||
- No lazy loading
|
||||
- Let users create as many messages as they want
|
||||
- Optimize later if problems occur
|
||||
|
||||
**When to add optimizations (post-MVP):**
|
||||
|
||||
- Users report slowness with large sessions
|
||||
- Measurable performance degradation
|
||||
- Memory usage becomes problematic
|
||||
- See Phase 8 tasks for virtual scrolling and optimization
|
||||
|
||||
## IPC Communication
|
||||
|
||||
### Main Process → Renderer
|
||||
|
||||
```typescript
|
||||
// Events sent from main to renderer
|
||||
type MainToRenderer = {
|
||||
"instance:started": { id: string; port: number; pid: number }
|
||||
"instance:error": { id: string; error: string }
|
||||
"instance:stopped": { id: string }
|
||||
"instance:log": { id: string; entry: LogEntry }
|
||||
}
|
||||
```
|
||||
|
||||
### Renderer → Main Process
|
||||
|
||||
```typescript
|
||||
// Commands sent from renderer to main
|
||||
type RendererToMain = {
|
||||
"folder:select": () => Promise<string | null>
|
||||
"instance:create": (folder: string) => Promise<{ port: number; pid: number }>
|
||||
"instance:stop": (pid: number) => Promise<void>
|
||||
"app:quit": () => void
|
||||
}
|
||||
```
|
||||
|
||||
### Preload Script (Bridge)
|
||||
|
||||
```typescript
|
||||
// Expose safe IPC methods to renderer
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
selectFolder: () => ipcRenderer.invoke("folder:select"),
|
||||
createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => ipcRenderer.on("instance:started", callback),
|
||||
onInstanceError: (callback) => ipcRenderer.on("instance:error", callback),
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
### Network Errors
|
||||
|
||||
```typescript
|
||||
// HTTP request fails
|
||||
try {
|
||||
const response = await client.session.list()
|
||||
} catch (error) {
|
||||
if (error.code === "ECONNREFUSED") {
|
||||
// Server not responding
|
||||
showError("Cannot connect to server. Is it running?")
|
||||
} else if (error.code === "ETIMEDOUT") {
|
||||
// Request timeout
|
||||
showError("Request timed out. Retry?", { retry: true })
|
||||
} else {
|
||||
// Unknown error
|
||||
showError(error.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSE Errors
|
||||
|
||||
```typescript
|
||||
eventSource.onerror = (error) => {
|
||||
// Connection lost
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
// Attempt reconnect
|
||||
reconnectSSE()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### User Input Errors
|
||||
|
||||
```typescript
|
||||
// Validate before sending
|
||||
function validatePrompt(text: string): string | null {
|
||||
if (!text.trim()) {
|
||||
return "Message cannot be empty"
|
||||
}
|
||||
if (text.length > 10000) {
|
||||
return "Message too long (max 10000 characters)"
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## Security Measures
|
||||
|
||||
### IPC Security
|
||||
|
||||
- Use `contextIsolation: true`
|
||||
- Whitelist allowed IPC channels
|
||||
- Validate all data from renderer
|
||||
- No `nodeIntegration` in renderer
|
||||
|
||||
### Process Security
|
||||
|
||||
- Spawn OpenCode with user permissions only
|
||||
- No shell execution of user input
|
||||
- Sanitize file paths
|
||||
|
||||
### Content Security
|
||||
|
||||
- Sanitize markdown before rendering
|
||||
- Use DOMPurify for HTML sanitization
|
||||
- No `dangerouslySetInnerHTML` without sanitization
|
||||
- CSP headers in renderer
|
||||
|
||||
## Testing Strategy (Future)
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- State management logic
|
||||
- Utility functions
|
||||
- Message parsing
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Process spawning
|
||||
- SDK client operations
|
||||
- SSE event handling
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Complete user flows
|
||||
- Multi-instance scenarios
|
||||
- Error recovery
|
||||
|
||||
## Build & Packaging
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start Electron + Vite dev server
|
||||
npm run dev:main # Main process only
|
||||
npm run dev:renderer # Renderer only
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
npm run build # Build all
|
||||
npm run build:main # Build main process
|
||||
npm run build:renderer # Build renderer
|
||||
npm run package # Create distributable
|
||||
```
|
||||
|
||||
### Distribution
|
||||
|
||||
- macOS: DMG + auto-update
|
||||
- Windows: NSIS installer + auto-update
|
||||
- Linux: AppImage + deb/rpm
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### electron.vite.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
plugins: [solid()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
493
dev-docs/user-interface.md
Normal file
493
dev-docs/user-interface.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# User Interface Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The CodeNomad interface consists of a two-level tabbed layout with instance tabs at the top and session tabs below. Each session displays a message stream and prompt input.
|
||||
|
||||
## Layout Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ File Edit View Window Help ● ○ ◐ │ ← Native menu bar
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [~/project-a] [~/project-a (2)] [~/api-service] [+] │ ← Instance tabs (Level 1)
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [Main] [Fix login] [Write tests] [Logs] [+] │ ← Session tabs (Level 2)
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Messages Area │ │
|
||||
│ │ │ │
|
||||
│ │ User: How do I set up testing? │ │
|
||||
│ │ │ │
|
||||
│ │ Assistant: To set up testing, you'll need to... │ │
|
||||
│ │ → bash: npm install vitest ✓ │ │
|
||||
│ │ Output: added 50 packages │ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Agent: Build ▼ Model: Claude 3.5 Sonnet ▼ │ ← Controls
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ [@file.ts] [@api.ts] [×] │ ← Attachments
|
||||
│ ┌────────────────────────────────────────────────────────┐ │
|
||||
│ │ Type your message or /command... │ │ ← Prompt input
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────────────────────────────┘ │
|
||||
│ [▶] │ ← Send button
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components Specification
|
||||
|
||||
### 1. Instance Tabs (Level 1)
|
||||
|
||||
**Visual Design:**
|
||||
|
||||
- Horizontal tabs at top of window
|
||||
- Each tab shows folder name
|
||||
- Icon: Folder icon (🗂️)
|
||||
- Close button (×) on hover
|
||||
- Active tab: Highlighted with accent color
|
||||
- Inactive tabs: Muted background
|
||||
|
||||
**Tab Label Format:**
|
||||
|
||||
- Single instance: `~/project-name`
|
||||
- Multiple instances of same folder: `~/project-name (2)`, `~/project-name (3)`
|
||||
- Max width: 200px with ellipsis for long paths
|
||||
- Tooltip shows full path on hover
|
||||
|
||||
**Actions:**
|
||||
|
||||
- Click: Switch to that instance
|
||||
- Close (×): Stop server and close instance (with confirmation)
|
||||
- Drag: Reorder tabs (future)
|
||||
|
||||
**New Instance Button (+):**
|
||||
|
||||
- Always visible at right end
|
||||
- Click: Opens folder picker dialog
|
||||
- Keyboard: Cmd/Ctrl+N
|
||||
|
||||
**States:**
|
||||
|
||||
- Starting: Loading spinner + "Starting..."
|
||||
- Ready: Normal appearance
|
||||
- Error: Red indicator + error icon
|
||||
- Stopped: Grayed out (should not be visible, tab closes)
|
||||
|
||||
### 2. Session Tabs (Level 2)
|
||||
|
||||
**Visual Design:**
|
||||
|
||||
- Horizontal tabs below instance tabs
|
||||
- Smaller than instance tabs
|
||||
- Each tab shows session title or "Untitled"
|
||||
- Active tab: Underline or bold
|
||||
- Parent-child relationship: No visual distinction (all siblings)
|
||||
|
||||
**Tab Types:**
|
||||
|
||||
**Session Tab:**
|
||||
|
||||
- Label: Session title (editable on double-click)
|
||||
- Icon: Chat bubble (💬) or none
|
||||
- Close button (×) on hover
|
||||
- Max width: 150px with ellipsis
|
||||
|
||||
**Logs Tab:**
|
||||
|
||||
- Label: "Logs"
|
||||
- Icon: Terminal (⚡)
|
||||
- Always present per instance
|
||||
- Non-closable
|
||||
- Shows server stdout/stderr
|
||||
|
||||
**Actions:**
|
||||
|
||||
- Click: Switch to that session
|
||||
- Double-click label: Rename session
|
||||
- Close (×): Delete session (with confirmation if has messages)
|
||||
- Right-click: Context menu (Share, Export, Delete)
|
||||
|
||||
**New Session Button (+):**
|
||||
|
||||
- Click: Creates new session with default agent
|
||||
- Keyboard: Cmd/Ctrl+T
|
||||
|
||||
### 3. Messages Area
|
||||
|
||||
**Container:**
|
||||
|
||||
- Scrollable viewport
|
||||
- Auto-scroll to bottom when new messages arrive
|
||||
- Manual scroll up: Disable auto-scroll
|
||||
- "Scroll to bottom" button appears when scrolled up
|
||||
|
||||
**Message Layout:**
|
||||
|
||||
**User Message:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ You 10:32 AM │
|
||||
│ How do I set up testing? │
|
||||
│ │
|
||||
│ [@src/app.ts] [@package.json] │ ← Attachments if any
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Assistant Message:**
|
||||
|
||||
````
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Assistant • Build 10:32 AM │
|
||||
│ To set up testing, you'll need to │
|
||||
│ install Vitest and configure it. │
|
||||
│ │
|
||||
│ ▶ bash: npm install vitest ✓ │ ← Tool call (collapsed)
|
||||
│ │
|
||||
│ ▶ edit src/vitest.config.ts ✓ │
|
||||
│ │
|
||||
│ Here's the configuration I added: │
|
||||
│ ```typescript │
|
||||
│ export default { │
|
||||
│ test: { globals: true } │
|
||||
│ } │
|
||||
│ ``` │
|
||||
└──────────────────────────────────────────┘
|
||||
````
|
||||
|
||||
**Tool Call (Collapsed):**
|
||||
|
||||
```
|
||||
▶ bash: npm install vitest ✓
|
||||
^ ^ ^
|
||||
| | |
|
||||
Icon Tool name + summary Status
|
||||
```
|
||||
|
||||
**Tool Call (Expanded):**
|
||||
|
||||
```
|
||||
▼ bash: npm install vitest ✓
|
||||
|
||||
Input:
|
||||
{
|
||||
"command": "npm install vitest"
|
||||
}
|
||||
|
||||
Output:
|
||||
added 50 packages, and audited 51 packages in 2s
|
||||
found 0 vulnerabilities
|
||||
```
|
||||
|
||||
**Status Icons:**
|
||||
|
||||
- ⏳ Pending (spinner)
|
||||
- ✓ Success (green checkmark)
|
||||
- ✗ Error (red X)
|
||||
- ⚠ Warning (yellow triangle)
|
||||
|
||||
**File Change Display:**
|
||||
|
||||
```
|
||||
▶ edit src/vitest.config.ts ✓
|
||||
Modified: src/vitest.config.ts
|
||||
+12 lines, -3 lines
|
||||
```
|
||||
|
||||
Click to expand: Show diff inline
|
||||
|
||||
### 4. Controls Bar
|
||||
|
||||
**Agent Selector:**
|
||||
|
||||
- Dropdown button showing current agent
|
||||
- Click: Opens dropdown with agent list
|
||||
- Shows: Agent name + description
|
||||
- Grouped by category (if applicable)
|
||||
|
||||
**Model Selector:**
|
||||
|
||||
- Dropdown button showing current model
|
||||
- Click: Opens dropdown with model list
|
||||
- Shows: Provider icon + Model name
|
||||
- Grouped by provider
|
||||
- Displays: Context window, capabilities icons
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Agent: Build ▼ Model: Claude 3.5 ▼ │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5. Prompt Input
|
||||
|
||||
**Input Field:**
|
||||
|
||||
- Multi-line textarea
|
||||
- Auto-expanding (max 10 lines)
|
||||
- Placeholder: "Type your message or /command..."
|
||||
- Supports keyboard shortcuts
|
||||
|
||||
**Features:**
|
||||
|
||||
**Slash Commands:**
|
||||
|
||||
- Type `/` → Autocomplete dropdown appears
|
||||
- Shows: Command name + description
|
||||
- Filter as you type
|
||||
- Enter to execute
|
||||
|
||||
**File Mentions:**
|
||||
|
||||
- Type `@` → File picker appears
|
||||
- Search files by name
|
||||
- Shows: File icon + path
|
||||
- Enter to attach
|
||||
|
||||
**Attachments:**
|
||||
|
||||
- Display as chips above input
|
||||
- Format: [@filename] [×]
|
||||
- Click × to remove
|
||||
- Drag & drop files onto input area
|
||||
|
||||
**Send Button:**
|
||||
|
||||
- Icon: Arrow (▶) or paper plane
|
||||
- Click: Submit message
|
||||
- Keyboard: Enter (without Shift)
|
||||
- Disabled when: Empty input or server busy
|
||||
|
||||
**Keyboard Shortcuts:**
|
||||
|
||||
- Enter: New line
|
||||
- Cmd+Enter (macOS) / Ctrl+Enter (Windows/Linux): Send message
|
||||
- Cmd/Ctrl+K: Clear input
|
||||
- Cmd/Ctrl+V: Paste (handles files)
|
||||
- Cmd/Ctrl+L: Focus input
|
||||
- Up/Down: Navigate message history (when input empty)
|
||||
|
||||
## Overlays & Modals
|
||||
|
||||
### Session Picker (Startup)
|
||||
|
||||
Appears when instance starts:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ OpenCode • ~/project-a │
|
||||
├────────────────────────────────────────┤
|
||||
│ Resume a session: │
|
||||
│ │
|
||||
│ > Fix login bug 2h ago │
|
||||
│ Add dark mode 5h ago │
|
||||
│ Refactor API Yesterday │
|
||||
│ │
|
||||
│ ────────────── or ────────────── │
|
||||
│ │
|
||||
│ Start new session: │
|
||||
│ Agent: [Build ▼] [Start] │
|
||||
│ │
|
||||
│ [Cancel] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
|
||||
- Click session: Resume that session
|
||||
- Click "Start": Create new session with selected agent
|
||||
- Click "Cancel": Close instance
|
||||
- Keyboard: Arrow keys to navigate, Enter to select
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
**Close Instance:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Stop OpenCode instance? │
|
||||
├────────────────────────────────────────┤
|
||||
│ This will stop the server for: │
|
||||
│ ~/project-a │
|
||||
│ │
|
||||
│ Active sessions will be lost. │
|
||||
│ │
|
||||
│ [Cancel] [Stop Instance] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Delete Session:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ Delete session? │
|
||||
├────────────────────────────────────────┤
|
||||
│ This will permanently delete: │
|
||||
│ "Fix login bug" │
|
||||
│ │
|
||||
│ This cannot be undone. │
|
||||
│ │
|
||||
│ [Cancel] [Delete] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Empty States
|
||||
|
||||
### No Instances
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [Folder Icon] │
|
||||
│ │
|
||||
│ Start Coding with AI │
|
||||
│ │
|
||||
│ Select a folder to start coding with AI │
|
||||
│ │
|
||||
│ [Select Folder] │
|
||||
│ │
|
||||
│ Keyboard shortcut: Cmd/Ctrl+N │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### No Messages (New Session)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Start a conversation │
|
||||
│ │
|
||||
│ Type a message below or try: │
|
||||
│ • /init-project │
|
||||
│ • Ask about your codebase │
|
||||
│ • Attach files with @ │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Logs Tab (No Logs Yet)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Waiting for server output... │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Visual Styling
|
||||
|
||||
### Color Scheme
|
||||
|
||||
**Light Mode:**
|
||||
|
||||
- Background: #FFFFFF
|
||||
- Secondary background: #F5F5F5
|
||||
- Border: #E0E0E0
|
||||
- Text: #1A1A1A
|
||||
- Muted text: #666666
|
||||
- Accent: #0066FF
|
||||
|
||||
**Dark Mode:**
|
||||
|
||||
- Background: #1A1A1A
|
||||
- Secondary background: #2A2A2A
|
||||
- Border: #3A3A3A
|
||||
- Text: #E0E0E0
|
||||
- Muted text: #999999
|
||||
- Accent: #0080FF
|
||||
|
||||
### Typography
|
||||
|
||||
- **Main text**: 14px, system font
|
||||
- **Headers**: 16px, medium weight
|
||||
- **Labels**: 12px, regular weight
|
||||
- **Code**: Monospace font (Consolas, Monaco, Courier)
|
||||
- **Line height**: 1.5
|
||||
|
||||
### Spacing
|
||||
|
||||
- **Padding**: 8px, 12px, 16px, 24px (consistent scale)
|
||||
- **Margins**: Same as padding
|
||||
- **Tab height**: 40px
|
||||
- **Input height**: 80px (auto-expanding)
|
||||
- **Message spacing**: 16px between messages
|
||||
|
||||
### Icons
|
||||
|
||||
- Use consistent icon set (Lucide, Heroicons, or similar)
|
||||
- Size: 16px for inline, 20px for buttons
|
||||
- Stroke width: 2px
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Minimum Window Size
|
||||
|
||||
- Width: 800px
|
||||
- Height: 600px
|
||||
|
||||
### Behavior When Small
|
||||
|
||||
- Instance tabs: Scroll horizontally
|
||||
- Session tabs: Scroll horizontally
|
||||
- Messages: Always visible, scroll vertically
|
||||
- Input: Fixed at bottom
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All interactive elements keyboard-navigable
|
||||
- ARIA labels for screen readers
|
||||
- Focus indicators visible
|
||||
- Color contrast WCAG AA compliant
|
||||
- Tab trap in modals
|
||||
- Escape key closes overlays
|
||||
|
||||
## Animation & Transitions
|
||||
|
||||
- Tab switching: Instant (no animation)
|
||||
- Message appearance: Fade in (100ms)
|
||||
- Tool expand/collapse: Slide (200ms)
|
||||
- Dropdown menus: Fade + slide (150ms)
|
||||
- Loading states: Spinner or skeleton
|
||||
|
||||
## Context Menus
|
||||
|
||||
### Session Tab Right-Click
|
||||
|
||||
- Rename
|
||||
- Duplicate
|
||||
- Share
|
||||
- Export
|
||||
- Delete
|
||||
- Close Other Tabs
|
||||
|
||||
### Message Right-Click
|
||||
|
||||
- Copy message
|
||||
- Copy code block
|
||||
- Edit & regenerate
|
||||
- Delete message
|
||||
- Quote in reply
|
||||
|
||||
## Status Indicators
|
||||
|
||||
### Instance Tab
|
||||
|
||||
- Green dot: Server running
|
||||
- Yellow dot: Server starting
|
||||
- Red dot: Server error
|
||||
- No dot: Server stopped
|
||||
|
||||
### Session Tab
|
||||
|
||||
- Blue pulse: Assistant responding
|
||||
- No indicator: Idle
|
||||
|
||||
### Connection Status
|
||||
|
||||
- Bottom right corner: "Connected" or "Reconnecting..."
|
||||
BIN
docs/screenshots/browser-support.png
Normal file
BIN
docs/screenshots/browser-support.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 845 KiB |
BIN
docs/screenshots/command-palette.png
Normal file
BIN
docs/screenshots/command-palette.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 835 KiB |
BIN
docs/screenshots/image-previews.png
Normal file
BIN
docs/screenshots/image-previews.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/newSession.png
Normal file
BIN
docs/screenshots/newSession.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
BIN
images/CodeNomad-Icon-original.png
Normal file
BIN
images/CodeNomad-Icon-original.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
images/CodeNomad-Icon.png
Normal file
BIN
images/CodeNomad-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
9042
package-lock.json
generated
Normal file
9042
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
packages/electron-app/scripts/dev.sh
Normal file → Executable file
0
packages/electron-app/scripts/dev.sh
Normal file → Executable file
@@ -16,11 +16,10 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -35,16 +35,10 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
reply.code(400).type("text/plain").send(message)
|
||||
}
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -8,11 +7,8 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
@@ -27,11 +23,9 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly opencodeConfigDir: string
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
this.opencodeConfigDir = getOpencodeConfigDir()
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
@@ -103,15 +97,10 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
}
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
|
||||
try {
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
@@ -119,8 +108,6 @@ export class WorkspaceManager {
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
@@ -246,159 +233,6 @@ export class WorkspaceManager {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited before becoming ready",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited shortly after start",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited during health checks",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
|
||||
const reason = probeResult.reason ?? "Health check failed"
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
|
||||
private buildStartupError(
|
||||
workspaceId: string,
|
||||
phase: string,
|
||||
exitInfo: ProcessExitInfo,
|
||||
lastOutput: string,
|
||||
): Error {
|
||||
const exitDetails = this.describeExit(exitInfo)
|
||||
const trimmedOutput = lastOutput.trim()
|
||||
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||
}
|
||||
|
||||
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let settled = false
|
||||
let retryTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const tryConnect = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolve()
|
||||
})
|
||||
socket.once("error", () => {
|
||||
socket.destroy()
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
cleanup()
|
||||
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||
} else {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
tryConnect()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
|
||||
private delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||
}
|
||||
|
||||
private describeExit(info: ProcessExitInfo): string {
|
||||
if (info.signal) {
|
||||
return `signal ${info.signal}`
|
||||
}
|
||||
if (info.code !== null) {
|
||||
return `code ${info.code}`
|
||||
}
|
||||
return "unknown reason"
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
@@ -13,7 +13,7 @@ interface LaunchOptions {
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
export interface ProcessExitInfo {
|
||||
interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
@@ -30,18 +30,12 @@ export class WorkspaceRuntime {
|
||||
|
||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
||||
exitResolve = resolveExit
|
||||
})
|
||||
let lastOutput = ""
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info(
|
||||
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||
@@ -89,21 +83,11 @@ export class WorkspaceRuntime {
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
child.removeListener("exit", handleExit)
|
||||
const exitInfo: ProcessExitInfo = {
|
||||
workspaceId: options.workspaceId,
|
||||
code,
|
||||
signal,
|
||||
requested: managed.requestedStop,
|
||||
}
|
||||
if (exitResolve) {
|
||||
exitResolve(exitInfo)
|
||||
exitResolve = null
|
||||
}
|
||||
if (!portFound) {
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.(exitInfo)
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +96,6 @@ export class WorkspaceRuntime {
|
||||
child.removeListener("exit", handleExit)
|
||||
this.processes.delete(options.workspaceId)
|
||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||
if (exitResolve) {
|
||||
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
||||
exitResolve = null
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
@@ -129,20 +109,18 @@ export class WorkspaceRuntime {
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
lastOutput = trimmed
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
||||
const getLastOutput = () => lastOutput.trim()
|
||||
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
||||
resolve({ pid: child.pid!, port })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,9 +133,7 @@ export class WorkspaceRuntime {
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
lastOutput = `[stderr] ${trimmed}`
|
||||
if (!line.trim()) continue
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
setHasInstances,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
} from "./stores/ui"
|
||||
import { instances as instanceStore } from "./stores/instances"
|
||||
import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
@@ -63,12 +65,7 @@ const App: Component = () => {
|
||||
setThinkingBlocksExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
@@ -108,30 +105,14 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchError()?.binaryPath
|
||||
const value = launchErrorBinary()
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return "Failed to launch workspace"
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const isMissingBinaryError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
@@ -142,7 +123,7 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
@@ -154,6 +135,7 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
@@ -162,13 +144,10 @@ const App: Component = () => {
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
@@ -212,6 +191,9 @@ const App: Component = () => {
|
||||
if (!confirmed) return
|
||||
|
||||
await stopInstance(instanceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession(instanceId: string) {
|
||||
@@ -322,7 +304,7 @@ const App: Component = () => {
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -330,8 +312,8 @@ const App: Component = () => {
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||
binary from Advanced Settings.
|
||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||
Advanced Settings.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
@@ -340,23 +322,10 @@ const App: Component = () => {
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import debug from "debug"
|
||||
|
||||
export type LoggerNamespace = "sse" | "api" | "session" | "actions" | "solo" | "multix-chat"
|
||||
export type LoggerNamespace = "sse" | "api" | "session" | "actions"
|
||||
|
||||
interface Logger {
|
||||
log: (...args: unknown[]) => void
|
||||
@@ -22,7 +22,7 @@ export interface LoggerControls {
|
||||
disableAllLoggers: () => void
|
||||
}
|
||||
|
||||
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions", "solo", "multix-chat"]
|
||||
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions"]
|
||||
const STORAGE_KEY = "opencode:logger:namespaces"
|
||||
|
||||
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
|
||||
|
||||
@@ -34,11 +34,6 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
|
||||
function syncHasInstancesFlag() {
|
||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||
setHasInstances(readyExists)
|
||||
}
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
@@ -73,6 +68,7 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready") {
|
||||
@@ -139,6 +135,9 @@ void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
@@ -160,6 +159,9 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
@@ -247,7 +249,6 @@ function addInstance(instance: Instance) {
|
||||
})
|
||||
ensureLogContainer(instance.id)
|
||||
ensureLogStreamingState(instance.id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
@@ -259,7 +260,6 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
}
|
||||
return next
|
||||
})
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function removeInstance(id: string) {
|
||||
@@ -301,7 +301,6 @@ function removeInstance(id: string) {
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||
@@ -329,6 +328,9 @@ async function stopInstance(id: string) {
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
@@ -588,6 +590,9 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
log.error("Failed to stop disconnected instance", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
177
tasks/README.md
Normal file
177
tasks/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Task Management
|
||||
|
||||
This directory contains the task breakdown for building CodeNomad.
|
||||
|
||||
## Structure
|
||||
|
||||
- `todo/` - Tasks waiting to be worked on
|
||||
- `done/` - Completed tasks (moved from todo/)
|
||||
|
||||
## Task Naming Convention
|
||||
|
||||
Tasks are numbered sequentially with a descriptive name:
|
||||
|
||||
```
|
||||
001-project-setup.md
|
||||
002-empty-state-ui.md
|
||||
003-process-manager.md
|
||||
...
|
||||
```
|
||||
|
||||
## Task Format
|
||||
|
||||
Each task file contains:
|
||||
|
||||
1. **Goal** - What this task achieves
|
||||
2. **Prerequisites** - What must be done first
|
||||
3. **Acceptance Criteria** - Checklist of requirements
|
||||
4. **Steps** - Detailed implementation guide
|
||||
5. **Testing Checklist** - How to verify completion
|
||||
6. **Dependencies** - What blocks/is blocked by this task
|
||||
7. **Estimated Time** - Rough time estimate
|
||||
8. **Notes** - Additional context
|
||||
|
||||
## Workflow
|
||||
|
||||
### Starting a Task
|
||||
|
||||
1. Read the task file thoroughly
|
||||
2. Ensure prerequisites are met
|
||||
3. Check dependencies are complete
|
||||
4. Create a feature branch: `feature/task-XXX-name`
|
||||
|
||||
### Working on a Task
|
||||
|
||||
1. Follow steps in order
|
||||
2. Check off acceptance criteria as you complete them
|
||||
3. Run tests frequently
|
||||
4. Commit regularly with descriptive messages
|
||||
|
||||
### Completing a Task
|
||||
|
||||
1. Verify all acceptance criteria met
|
||||
2. Run full testing checklist
|
||||
3. Update task file with any notes/changes
|
||||
4. Move task from `todo/` to `done/`
|
||||
5. Create PR for review
|
||||
|
||||
## Current Tasks
|
||||
|
||||
### Phase 1: Foundation (Tasks 001-005)
|
||||
|
||||
- [x] 001 - Project Setup
|
||||
- [x] 002 - Empty State UI
|
||||
- [x] 003 - Process Manager
|
||||
- [x] 004 - SDK Integration
|
||||
- [x] 005 - Session Picker Modal
|
||||
|
||||
### Phase 2: Core Chat (Tasks 006-010)
|
||||
|
||||
- [x] 006 - Instance & Session Tabs
|
||||
- [x] 007 - Message Display
|
||||
- [x] 008 - SSE Integration
|
||||
- [x] 009 - Prompt Input (Basic)
|
||||
- [x] 010 - Tool Call Rendering
|
||||
|
||||
### Phase 3: Essential Features (Tasks 011-015)
|
||||
|
||||
- [x] 011 - Agent/Model Selectors
|
||||
- [x] 012 - Markdown Rendering
|
||||
- [x] 013 - Logs Tab
|
||||
- [ ] 014 - Error Handling
|
||||
- [ ] 015 - Keyboard Shortcuts
|
||||
|
||||
### Phase 4: Multi-Instance (Tasks 016-020)
|
||||
|
||||
- [ ] 016 - Instance Tabs
|
||||
- [ ] 017 - Instance Persistence
|
||||
- [ ] 018 - Child Session Handling
|
||||
- [ ] 019 - Instance Lifecycle
|
||||
- [ ] 020 - Multiple SDK Clients
|
||||
|
||||
### Phase 5: Advanced Input (Tasks 021-025)
|
||||
|
||||
- [ ] 021 - Slash Commands
|
||||
- [ ] 022 - File Attachments
|
||||
- [ ] 023 - Drag & Drop
|
||||
- [ ] 024 - Attachment Chips
|
||||
- [ ] 025 - Input History
|
||||
|
||||
### Phase 6: Polish (Tasks 026-030)
|
||||
|
||||
- [ ] 026 - Message Actions
|
||||
- [ ] 027 - Search in Session
|
||||
- [ ] 028 - Session Management
|
||||
- [ ] 029 - Settings UI
|
||||
- [ ] 030 - Native Menus
|
||||
|
||||
### Phase 7: System Integration (Tasks 031-035)
|
||||
|
||||
- [ ] 031 - System Tray
|
||||
- [ ] 032 - Notifications
|
||||
- [ ] 033 - Auto-updater
|
||||
- [ ] 034 - Crash Reporting
|
||||
- [ ] 035 - Performance Profiling
|
||||
|
||||
### Phase 8: Advanced (Tasks 036-040)
|
||||
|
||||
- [ ] 036 - Virtual Scrolling
|
||||
- [ ] 037 - Advanced Search
|
||||
- [ ] 038 - Workspace Management
|
||||
- [ ] 039 - Theme Customization
|
||||
- [ ] 040 - Plugin System
|
||||
|
||||
## Priority Levels
|
||||
|
||||
Tasks are prioritized as follows:
|
||||
|
||||
- **P0 (MVP)**: Must have for first release (Tasks 001-015)
|
||||
- **P1 (Beta)**: Important for beta (Tasks 016-030)
|
||||
- **P2 (v1.0)**: Should have for v1.0 (Tasks 031-035)
|
||||
- **P3 (Future)**: Nice to have (Tasks 036-040)
|
||||
|
||||
## Dependencies Graph
|
||||
|
||||
```
|
||||
001 (Setup)
|
||||
├─ 002 (Empty State)
|
||||
│ └─ 003 (Process Manager)
|
||||
│ └─ 004 (SDK Integration)
|
||||
│ └─ 005 (Session Picker)
|
||||
│ ├─ 006 (Tabs)
|
||||
│ │ └─ 007 (Messages)
|
||||
│ │ └─ 008 (SSE)
|
||||
│ │ └─ 009 (Input)
|
||||
│ │ └─ 010 (Tool Calls)
|
||||
│ │ └─ 011-015 (Essential Features)
|
||||
│ │ └─ 016-020 (Multi-Instance)
|
||||
│ │ └─ 021-025 (Advanced Input)
|
||||
│ │ └─ 026-030 (Polish)
|
||||
│ │ └─ 031-035 (System)
|
||||
│ │ └─ 036-040 (Advanced)
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't skip steps** - They're ordered for a reason
|
||||
- **Test as you go** - Don't wait until the end
|
||||
- **Keep tasks small** - Break down if >1 day of work
|
||||
- **Document issues** - Note any blockers or problems
|
||||
- **Ask questions** - If unclear, ask before proceeding
|
||||
|
||||
## Tracking Progress
|
||||
|
||||
Update this file as tasks complete:
|
||||
|
||||
- Change `[ ]` to `[x]` in the task list
|
||||
- Move completed task files to `done/`
|
||||
- Update build roadmap doc
|
||||
|
||||
## Getting Help
|
||||
|
||||
If stuck on a task:
|
||||
|
||||
1. Review prerequisites and dependencies
|
||||
2. Check related documentation in `docs/`
|
||||
3. Review similar patterns in existing code
|
||||
4. Ask for clarification on unclear requirements
|
||||
262
tasks/done/001-project-setup.md
Normal file
262
tasks/done/001-project-setup.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Task 001: Project Setup & Boilerplate
|
||||
|
||||
## Goal
|
||||
|
||||
Set up the basic Electron + SolidJS + Vite project structure with all necessary dependencies and configuration files.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ installed
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and accessible in PATH
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Project structure matches documented layout
|
||||
- [ ] All dependencies installed
|
||||
- [ ] Dev server starts successfully
|
||||
- [ ] Electron window launches
|
||||
- [ ] Hot reload works for renderer
|
||||
- [ ] TypeScript compilation works
|
||||
- [ ] Basic "Hello World" renders
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Initialize Package
|
||||
|
||||
- Create `package.json` with project metadata
|
||||
- Set `name`: `@opencode-ai/client`
|
||||
- Set `version`: `0.1.0`
|
||||
- Set `type`: `module`
|
||||
- Set `main`: `dist/main/main.js`
|
||||
|
||||
### 2. Install Core Dependencies
|
||||
|
||||
**Production:**
|
||||
|
||||
- `electron` ^28.0.0
|
||||
- `solid-js` ^1.8.0
|
||||
- `@solidjs/router` ^0.13.0
|
||||
- `@opencode-ai/sdk` (from workspace)
|
||||
|
||||
**Development:**
|
||||
|
||||
- `electron-vite` ^2.0.0
|
||||
- `electron-builder` ^24.0.0
|
||||
- `vite` ^5.0.0
|
||||
- `vite-plugin-solid` ^2.10.0
|
||||
- `typescript` ^5.3.0
|
||||
- `tailwindcss` ^4.0.0
|
||||
- `@tailwindcss/vite` ^4.0.0
|
||||
|
||||
**UI Libraries:**
|
||||
|
||||
- `@kobalte/core` ^0.13.0
|
||||
- `shiki` ^1.0.0
|
||||
- `marked` ^12.0.0
|
||||
- `lucide-solid` ^0.300.0
|
||||
|
||||
### 3. Create Directory Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── electron/
|
||||
│ ├── main/
|
||||
│ │ └── main.ts
|
||||
│ ├── preload/
|
||||
│ │ └── index.ts
|
||||
│ └── resources/
|
||||
│ └── icon.png
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── stores/
|
||||
│ ├── lib/
|
||||
│ ├── hooks/
|
||||
│ ├── types/
|
||||
│ ├── App.tsx
|
||||
│ ├── main.tsx
|
||||
│ └── index.css
|
||||
├── docs/
|
||||
├── tasks/
|
||||
│ ├── todo/
|
||||
│ └── done/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsconfig.node.json
|
||||
├── electron.vite.config.ts
|
||||
├── tailwind.config.js
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 4. Configure TypeScript
|
||||
|
||||
**tsconfig.json** (for renderer):
|
||||
|
||||
- `target`: ES2020
|
||||
- `module`: ESNext
|
||||
- `jsx`: preserve
|
||||
- `jsxImportSource`: solid-js
|
||||
- `moduleResolution`: bundler
|
||||
- `strict`: true
|
||||
- Path alias: `@/*` → `./src/*`
|
||||
|
||||
**tsconfig.node.json** (for main & preload):
|
||||
|
||||
- `target`: ES2020
|
||||
- `module`: ESNext
|
||||
- `moduleResolution`: bundler
|
||||
- Include: `electron/**/*.ts`
|
||||
|
||||
### 5. Configure Electron Vite
|
||||
|
||||
**electron.vite.config.ts:**
|
||||
|
||||
- Main process config: External electron
|
||||
- Preload config: External electron
|
||||
- Renderer config:
|
||||
- SolidJS plugin
|
||||
- TailwindCSS plugin
|
||||
- Path alias resolution
|
||||
- Dev server port: 3000
|
||||
|
||||
### 6. Configure TailwindCSS
|
||||
|
||||
**tailwind.config.js:**
|
||||
|
||||
- Content: `['./src/**/*.{ts,tsx}']`
|
||||
- Theme: Default (will customize later)
|
||||
- Plugins: None initially
|
||||
|
||||
**src/index.css:**
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
### 7. Create Main Process Entry
|
||||
|
||||
**electron/main/main.ts:**
|
||||
|
||||
- Import app, BrowserWindow from electron
|
||||
- Set up window creation
|
||||
- Window size: 1400x900
|
||||
- Min size: 800x600
|
||||
- Web preferences:
|
||||
- preload: path to preload script
|
||||
- contextIsolation: true
|
||||
- nodeIntegration: false
|
||||
- Load URL based on environment:
|
||||
- Dev: http://localhost:3000
|
||||
- Prod: Load dist/index.html
|
||||
- Handle app lifecycle:
|
||||
- ready event
|
||||
- window-all-closed (quit on non-macOS)
|
||||
- activate (recreate window on macOS)
|
||||
|
||||
### 8. Create Preload Script
|
||||
|
||||
**electron/preload/index.ts:**
|
||||
|
||||
- Import contextBridge, ipcRenderer
|
||||
- Expose electronAPI object:
|
||||
- Placeholder methods for future IPC
|
||||
- Type definitions for window.electronAPI
|
||||
|
||||
### 9. Create Renderer Entry
|
||||
|
||||
**src/main.tsx:**
|
||||
|
||||
- Import render from solid-js/web
|
||||
- Import App component
|
||||
- Render to #root element
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
- Basic component with "Hello CodeNomad"
|
||||
- Display environment info
|
||||
- Basic styling with TailwindCSS
|
||||
|
||||
**index.html:**
|
||||
|
||||
- Root div with id="root"
|
||||
- Link to src/main.tsx
|
||||
|
||||
### 10. Add Scripts to package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
||||
"preview": "electron-vite preview",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Configure Electron Builder
|
||||
|
||||
**electron-builder.yml** or in package.json:
|
||||
|
||||
- appId: ai.opencode.client
|
||||
- Product name: CodeNomad
|
||||
- Build resources: electron/resources
|
||||
- Files to include: dist/, package.json
|
||||
- Directories:
|
||||
- output: release
|
||||
- buildResources: electron/resources
|
||||
- Platform-specific configs (basic)
|
||||
|
||||
### 12. Add .gitignore
|
||||
|
||||
```
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite/
|
||||
.electron-vite/
|
||||
```
|
||||
|
||||
### 13. Create README
|
||||
|
||||
- Project description
|
||||
- Prerequisites
|
||||
- Installation instructions
|
||||
- Development commands
|
||||
- Build commands
|
||||
- Architecture overview link
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Run `bun install`
|
||||
2. Run `bun run dev`
|
||||
3. Verify Electron window opens
|
||||
4. Verify "Hello CodeNomad" displays
|
||||
5. Make a change to App.tsx
|
||||
6. Verify hot reload updates UI
|
||||
7. Run `bun run typecheck`
|
||||
8. Verify no TypeScript errors
|
||||
9. Run `bun run build`
|
||||
10. Verify dist/ folder created
|
||||
|
||||
## Dependencies for Next Tasks
|
||||
|
||||
- Task 002 (Empty State) depends on this
|
||||
- Task 003 (Process Manager) depends on this
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep this minimal - just the skeleton
|
||||
- Don't add any business logic yet
|
||||
- Focus on getting build pipeline working
|
||||
- Use official Electron + Vite + Solid templates as reference
|
||||
280
tasks/done/002-empty-state-ui.md
Normal file
280
tasks/done/002-empty-state-ui.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Task 002: Empty State UI & Folder Selection
|
||||
|
||||
## Goal
|
||||
|
||||
Create the initial empty state interface that appears when no instances are running, with folder selection capability.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 001 completed (project setup)
|
||||
- Basic understanding of SolidJS components
|
||||
- Electron IPC understanding
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Empty state displays when no instances exist
|
||||
- [ ] "Select Folder" button visible and styled
|
||||
- [ ] Clicking button triggers Electron dialog
|
||||
- [ ] Selected folder path displays temporarily
|
||||
- [ ] UI matches design spec (centered, clean)
|
||||
- [ ] Keyboard shortcut Cmd/Ctrl+N works
|
||||
- [ ] Error handling for cancelled selection
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Empty State Component
|
||||
|
||||
**src/components/empty-state.tsx:**
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Centered container
|
||||
- Large folder icon (from lucide-solid)
|
||||
- Subheading: "Select a folder to start coding with AI"
|
||||
- Primary button: "Select Folder"
|
||||
- Helper text: "Keyboard shortcut: Cmd/Ctrl+N"
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Use TailwindCSS utilities
|
||||
- Center vertically and horizontally
|
||||
- Max width: 500px
|
||||
- Padding: 32px
|
||||
- Icon size: 64px
|
||||
- Text sizes: Heading 24px, body 16px, helper 14px
|
||||
- Colors: Follow design spec (light/dark mode)
|
||||
|
||||
**Props:**
|
||||
|
||||
- `onSelectFolder: () => void` - Callback when button clicked
|
||||
|
||||
### 2. Create UI Store
|
||||
|
||||
**src/stores/ui.ts:**
|
||||
|
||||
**State:**
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
hasInstances: boolean
|
||||
selectedFolder: string | null
|
||||
isSelectingFolder: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Signals:**
|
||||
|
||||
- `hasInstances` - Reactive boolean
|
||||
- `selectedFolder` - Reactive string or null
|
||||
- `isSelectingFolder` - Reactive boolean (loading state)
|
||||
|
||||
**Actions:**
|
||||
|
||||
- `setHasInstances(value: boolean)`
|
||||
- `setSelectedFolder(path: string | null)`
|
||||
- `setIsSelectingFolder(value: boolean)`
|
||||
|
||||
### 3. Implement IPC for Folder Selection
|
||||
|
||||
**electron/main/main.ts additions:**
|
||||
|
||||
**IPC Handler:**
|
||||
|
||||
- Register handler for 'dialog:selectFolder'
|
||||
- Use `dialog.showOpenDialog()` with:
|
||||
- `properties: ['openDirectory']`
|
||||
- Title: "Select Project Folder"
|
||||
- Button label: "Select"
|
||||
- Return selected folder path or null if cancelled
|
||||
- Handle errors gracefully
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
**Expose method:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
selectFolder: () => Promise<string | null>
|
||||
}
|
||||
```
|
||||
|
||||
**Type definitions:**
|
||||
|
||||
```typescript
|
||||
interface ElectronAPI {
|
||||
selectFolder: () => Promise<string | null>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update App Component
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
**Logic:**
|
||||
|
||||
- Import UI store
|
||||
- Import EmptyState component
|
||||
- Check if `hasInstances` is false
|
||||
- If false, render EmptyState
|
||||
- If true, render placeholder for instance UI (future)
|
||||
|
||||
**Folder selection handler:**
|
||||
|
||||
```typescript
|
||||
async function handleSelectFolder() {
|
||||
setIsSelectingFolder(true)
|
||||
try {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
setSelectedFolder(folder)
|
||||
// TODO: Will trigger instance creation in Task 003
|
||||
console.log("Selected folder:", folder)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Folder selection failed:", error)
|
||||
// TODO: Show error toast (Task 010)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Add Keyboard Shortcut
|
||||
|
||||
**electron/main/menu.ts (new file):**
|
||||
|
||||
**Create application menu:**
|
||||
|
||||
- File menu:
|
||||
- New Instance (Cmd/Ctrl+N)
|
||||
- Click: Send 'menu:newInstance' to renderer
|
||||
- Separator
|
||||
- Quit (Cmd/Ctrl+Q)
|
||||
|
||||
**Platform-specific menu:**
|
||||
|
||||
- macOS: Include app menu with About, Hide, etc.
|
||||
- Windows/Linux: Standard File menu
|
||||
|
||||
**Register menu in main.ts:**
|
||||
|
||||
- Import Menu, buildFromTemplate
|
||||
- Create menu structure
|
||||
- Set as application menu
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
onNewInstance: (callback: () => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
**src/App.tsx additions:**
|
||||
|
||||
- Listen for 'newInstance' event
|
||||
- Trigger handleSelectFolder when received
|
||||
|
||||
### 6. Add Loading State
|
||||
|
||||
**Button states:**
|
||||
|
||||
- Default: "Select Folder"
|
||||
- Loading: "Selecting..." with spinner icon
|
||||
- Disabled when isSelectingFolder is true
|
||||
|
||||
**Spinner component:**
|
||||
|
||||
- Use lucide-solid Loader2 icon
|
||||
- Add spin animation class
|
||||
- Size: 16px
|
||||
|
||||
### 7. Add Validation
|
||||
|
||||
**Folder validation (in handler):**
|
||||
|
||||
- Check if folder exists
|
||||
- Check if readable
|
||||
- Check if it's actually a directory
|
||||
- Show appropriate error if invalid
|
||||
|
||||
**Error messages:**
|
||||
|
||||
- "Folder does not exist"
|
||||
- "Cannot access folder (permission denied)"
|
||||
- "Please select a directory, not a file"
|
||||
|
||||
### 8. Style Refinements
|
||||
|
||||
**Responsive behavior:**
|
||||
|
||||
- Works at minimum window size (800x600)
|
||||
- Maintains centering
|
||||
- Text remains readable
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Button has proper ARIA labels
|
||||
- Keyboard focus visible
|
||||
- Screen reader friendly text
|
||||
|
||||
**Theme support:**
|
||||
|
||||
- Test in light mode
|
||||
- Test in dark mode (use prefers-color-scheme)
|
||||
- Icons and text have proper contrast
|
||||
|
||||
### 9. Add Helpful Context
|
||||
|
||||
**Additional helper text:**
|
||||
|
||||
- "Examples: ~/projects/my-app"
|
||||
- "You can have multiple instances of the same folder"
|
||||
|
||||
**Icon improvements:**
|
||||
|
||||
- Use animated folder icon (optional)
|
||||
- Add subtle entrance animation (fade in)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Launch app → Empty state appears
|
||||
2. Click "Select Folder" → Dialog opens
|
||||
3. Select folder → Path logged to console
|
||||
4. Cancel dialog → No error, back to empty state
|
||||
5. Press Cmd/Ctrl+N → Dialog opens
|
||||
6. Select non-directory → Error shown
|
||||
7. Select restricted folder → Permission error shown
|
||||
8. Resize window → Layout stays centered
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Very long folder paths (ellipsis)
|
||||
- Special characters in folder name
|
||||
- Folder on network drive
|
||||
- Folder that gets deleted while selected
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 003 (needs folder path to create instance)
|
||||
- **Blocked by:** Task 001 (needs project setup)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep UI simple and clean
|
||||
- Focus on UX - clear messaging
|
||||
- Don't implement instance creation yet (that's Task 003)
|
||||
- Log selected folder to console for verification
|
||||
- Prepare for state management patterns used in later tasks
|
||||
430
tasks/done/003-process-manager.md
Normal file
430
tasks/done/003-process-manager.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Task 003: OpenCode Server Process Management
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the ability to spawn, manage, and kill OpenCode server processes from the Electron main process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 001 completed (project setup)
|
||||
- Task 002 completed (folder selection working)
|
||||
- OpenCode CLI installed and in PATH
|
||||
- Understanding of Node.js child_process API
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can spawn `opencode serve` for a folder
|
||||
- [ ] Parses stdout to extract port number
|
||||
- [ ] Returns port and PID to renderer
|
||||
- [ ] Handles spawn errors gracefully
|
||||
- [ ] Can kill process on command
|
||||
- [ ] Captures and forwards stdout/stderr
|
||||
- [ ] Timeout protection (10 seconds)
|
||||
- [ ] Process cleanup on app quit
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Process Manager Module
|
||||
|
||||
**electron/main/process-manager.ts:**
|
||||
|
||||
**Exports:**
|
||||
|
||||
```typescript
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ProcessManager {
|
||||
spawn(folder: string): Promise<ProcessInfo>
|
||||
kill(pid: number): Promise<void>
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown"
|
||||
getAllProcesses(): Map<number, ProcessMeta>
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Implement Spawn Logic
|
||||
|
||||
**spawn(folder: string):**
|
||||
|
||||
**Pre-flight checks:**
|
||||
|
||||
- Verify `opencode` binary exists in PATH
|
||||
- Use `which opencode` or `where opencode`
|
||||
- If not found, reject with helpful error
|
||||
- Verify folder exists and is directory
|
||||
- Use `fs.stat()` to check
|
||||
- If invalid, reject with error
|
||||
- Verify folder is readable
|
||||
- Check permissions
|
||||
- If denied, reject with error
|
||||
|
||||
**Process spawning:**
|
||||
|
||||
- Use `child_process.spawn()`
|
||||
- Command: `opencode`
|
||||
- Args: `['serve', '--port', '0']`
|
||||
- Port 0 = random available port
|
||||
- Options:
|
||||
- `cwd`: The selected folder
|
||||
- `stdio`: `['ignore', 'pipe', 'pipe']`
|
||||
- stdin: ignore
|
||||
- stdout: pipe (we'll read it)
|
||||
- stderr: pipe (for errors)
|
||||
- `env`: Inherit process.env
|
||||
- `shell`: false (security)
|
||||
|
||||
**Port extraction:**
|
||||
|
||||
- Listen to stdout data events
|
||||
- Buffer output line by line
|
||||
- Regex match: `/Server listening on port (\d+)/` or similar
|
||||
- Extract port number when found
|
||||
- Store process metadata
|
||||
- Resolve promise with { pid, port }
|
||||
|
||||
**Timeout handling:**
|
||||
|
||||
- Set 10 second timeout
|
||||
- If port not found within timeout:
|
||||
- Kill the process
|
||||
- Reject promise with timeout error
|
||||
- Clear timeout once port found
|
||||
|
||||
**Error handling:**
|
||||
|
||||
- Listen to process 'error' event
|
||||
- Common: ENOENT (binary not found)
|
||||
- Reject promise immediately
|
||||
- Listen to process 'exit' event
|
||||
- If exits before port found:
|
||||
- Read stderr buffer
|
||||
- Reject with exit code and stderr
|
||||
|
||||
### 3. Implement Kill Logic
|
||||
|
||||
**kill(pid: number):**
|
||||
|
||||
**Find process:**
|
||||
|
||||
- Look up pid in internal Map
|
||||
- If not found, reject with "Process not found"
|
||||
|
||||
**Graceful shutdown:**
|
||||
|
||||
- Send SIGTERM signal first
|
||||
- Wait 2 seconds
|
||||
- If still running, send SIGKILL
|
||||
- Remove from internal Map
|
||||
- Resolve when process exits
|
||||
|
||||
**Cleanup:**
|
||||
|
||||
- Close stdio streams
|
||||
- Remove all event listeners
|
||||
- Free resources
|
||||
|
||||
### 4. Implement Status Check
|
||||
|
||||
**getStatus(pid: number):**
|
||||
|
||||
**Check if running:**
|
||||
|
||||
- On Unix: Use `process.kill(pid, 0)`
|
||||
- Returns true if running
|
||||
- Throws if not running
|
||||
- On Windows: Use tasklist or similar
|
||||
- Return 'running', 'stopped', or 'unknown'
|
||||
|
||||
### 5. Add Process Tracking
|
||||
|
||||
**Internal state:**
|
||||
|
||||
```typescript
|
||||
const processes = new Map<number, ProcessMeta>()
|
||||
```
|
||||
|
||||
**Track all spawned processes:**
|
||||
|
||||
- Add on successful spawn
|
||||
- Remove on kill or exit
|
||||
- Use for cleanup on app quit
|
||||
|
||||
### 6. Implement Auto-cleanup
|
||||
|
||||
**On app quit:**
|
||||
|
||||
- Listen to app 'before-quit' event
|
||||
- Kill all tracked processes
|
||||
- Wait for all to exit (with timeout)
|
||||
- Prevent quit until cleanup done
|
||||
|
||||
**On process crash:**
|
||||
|
||||
- Listen to process 'exit' event
|
||||
- If unexpected exit:
|
||||
- Log error
|
||||
- Notify renderer via IPC
|
||||
- Remove from tracking
|
||||
|
||||
### 7. Add Logging
|
||||
|
||||
**Log output forwarding:**
|
||||
|
||||
- Listen to stdout/stderr
|
||||
- Parse into lines
|
||||
- Send to renderer via IPC events
|
||||
- Event: 'instance:log'
|
||||
- Payload: { pid, level: 'info' | 'error', message }
|
||||
|
||||
**Log important events:**
|
||||
|
||||
- Process spawned
|
||||
- Port discovered
|
||||
- Process exited
|
||||
- Errors occurred
|
||||
|
||||
### 8. Add IPC Handlers
|
||||
|
||||
**electron/main/ipc.ts (new file):**
|
||||
|
||||
**Register handlers:**
|
||||
|
||||
```typescript
|
||||
ipcMain.handle("process:spawn", async (event, folder: string) => {
|
||||
return await processManager.spawn(folder)
|
||||
})
|
||||
|
||||
ipcMain.handle("process:kill", async (event, pid: number) => {
|
||||
return await processManager.kill(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("process:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
```
|
||||
|
||||
**Send events:**
|
||||
|
||||
```typescript
|
||||
// When process exits unexpectedly
|
||||
webContents.send("process:exited", { pid, code, signal })
|
||||
|
||||
// When log output received
|
||||
webContents.send("process:log", { pid, level, message })
|
||||
```
|
||||
|
||||
### 9. Update Preload Script
|
||||
|
||||
**electron/preload/index.ts additions:**
|
||||
|
||||
**Expose methods:**
|
||||
|
||||
```typescript
|
||||
electronAPI: {
|
||||
spawnServer: (folder: string) => Promise<{ pid: number, port: number }>
|
||||
killServer: (pid: number) => Promise<void>
|
||||
getServerStatus: (pid: number) => Promise<string>
|
||||
|
||||
onServerExited: (callback: (data: any) => void) => void
|
||||
onServerLog: (callback: (data: any) => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Type definitions:**
|
||||
|
||||
```typescript
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
// ... previous methods
|
||||
spawnServer: (folder: string) => Promise<ProcessInfo>
|
||||
killServer: (pid: number) => Promise<void>
|
||||
getServerStatus: (pid: number) => Promise<"running" | "stopped" | "unknown">
|
||||
onServerExited: (callback: (data: { pid: number; code: number }) => void) => void
|
||||
onServerLog: (callback: (data: { pid: number; level: string; message: string }) => void) => void
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Create Instance Store
|
||||
|
||||
**src/stores/instances.ts:**
|
||||
|
||||
**State:**
|
||||
|
||||
```typescript
|
||||
interface Instance {
|
||||
id: string // UUID
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface InstanceStore {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
const id = generateId()
|
||||
|
||||
// Add with 'starting' status
|
||||
instances.set(id, {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
})
|
||||
|
||||
try {
|
||||
// Spawn server
|
||||
const { pid, port } = await window.electronAPI.spawnServer(folder)
|
||||
|
||||
// Update with port and pid
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
port,
|
||||
pid,
|
||||
status: "ready",
|
||||
})
|
||||
|
||||
return id
|
||||
} catch (error) {
|
||||
// Update with error
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
status: "error",
|
||||
error: error.message,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeInstance(id: string) {
|
||||
const instance = instances.get(id)
|
||||
if (!instance) return
|
||||
|
||||
// Kill server
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.killServer(instance.pid)
|
||||
}
|
||||
|
||||
// Remove from store
|
||||
instances.delete(id)
|
||||
|
||||
// If was active, clear active
|
||||
if (activeInstanceId === id) {
|
||||
activeInstanceId = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Wire Up Folder Selection
|
||||
|
||||
**src/App.tsx updates:**
|
||||
|
||||
**After folder selected:**
|
||||
|
||||
```typescript
|
||||
async function handleSelectFolder() {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (!folder) return
|
||||
|
||||
try {
|
||||
const instanceId = await createInstance(folder)
|
||||
setActiveInstance(instanceId)
|
||||
|
||||
// Hide empty state, show instance UI
|
||||
setHasInstances(true)
|
||||
} catch (error) {
|
||||
console.error("Failed to create instance:", error)
|
||||
// TODO: Show error toast
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Listen for process exit:**
|
||||
|
||||
```typescript
|
||||
onMount(() => {
|
||||
window.electronAPI.onServerExited(({ pid }) => {
|
||||
// Find instance by PID
|
||||
const instance = Array.from(instances.values()).find((i) => i.pid === pid)
|
||||
|
||||
if (instance) {
|
||||
// Update status
|
||||
instances.set(instance.id, {
|
||||
...instance,
|
||||
status: "stopped",
|
||||
})
|
||||
|
||||
// TODO: Show notification (Task 010)
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Select folder → Server spawns
|
||||
2. Console shows "Spawned PID: XXX, Port: YYYY"
|
||||
3. Check `ps aux | grep opencode` → Process running
|
||||
4. Quit app → Process killed
|
||||
5. Select invalid folder → Error shown
|
||||
6. Select without opencode installed → Helpful error
|
||||
7. Spawn multiple instances → All tracked
|
||||
8. Kill one instance → Others continue running
|
||||
|
||||
**Error Cases:**
|
||||
|
||||
- opencode not in PATH
|
||||
- Permission denied on folder
|
||||
- Port already in use (should not happen with port 0)
|
||||
- Server crashes immediately
|
||||
- Timeout (server takes >10s to start)
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Very long folder path
|
||||
- Folder with spaces in name
|
||||
- Folder on network drive (slow to spawn)
|
||||
- Multiple instances same folder (different ports)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 004 (needs running server to connect SDK)
|
||||
- **Blocked by:** Task 001, Task 002
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Security: Never use shell execution with user input
|
||||
- Cross-platform: Test on macOS, Windows, Linux
|
||||
- Error messages must be actionable
|
||||
- Log everything for debugging
|
||||
- Consider rate limiting (max 10 instances?)
|
||||
- Memory: Track process memory usage (future enhancement)
|
||||
504
tasks/done/004-sdk-integration.md
Normal file
504
tasks/done/004-sdk-integration.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Task 004: SDK Client Integration & Session Management
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate the OpenCode SDK to communicate with running servers, fetch session lists, and manage session lifecycle.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 003 completed (server spawning works)
|
||||
- OpenCode SDK package available
|
||||
- Understanding of HTTP/REST APIs
|
||||
- Understanding of SolidJS reactivity
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] SDK client created per instance
|
||||
- [ ] Can fetch session list from server
|
||||
- [ ] Can create new session
|
||||
- [ ] Can get session details
|
||||
- [ ] Can delete session
|
||||
- [ ] Client lifecycle tied to instance lifecycle
|
||||
- [ ] Error handling for network failures
|
||||
- [ ] Proper TypeScript types throughout
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create SDK Manager Module
|
||||
|
||||
**src/lib/sdk-manager.ts:**
|
||||
|
||||
**Purpose:**
|
||||
|
||||
- Manage SDK client instances
|
||||
- One client per server (per port)
|
||||
- Create, retrieve, destroy clients
|
||||
|
||||
**Interface:**
|
||||
|
||||
```typescript
|
||||
interface SDKManager {
|
||||
createClient(port: number): OpenCodeClient
|
||||
getClient(port: number): OpenCodeClient | null
|
||||
destroyClient(port: number): void
|
||||
destroyAll(): void
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation details:**
|
||||
|
||||
- Store clients in Map<port, client>
|
||||
- Create client with base URL: `http://localhost:${port}`
|
||||
- Handle client creation errors
|
||||
- Clean up on destroy
|
||||
|
||||
### 2. Update Instance Store
|
||||
|
||||
**src/stores/instances.ts additions:**
|
||||
|
||||
**Add client to Instance:**
|
||||
|
||||
```typescript
|
||||
interface Instance {
|
||||
// ... existing fields
|
||||
client: OpenCodeClient | null
|
||||
}
|
||||
```
|
||||
|
||||
**Update createInstance:**
|
||||
|
||||
- After server spawns successfully
|
||||
- Create SDK client for that port
|
||||
- Store in instance.client
|
||||
- Handle client creation errors
|
||||
|
||||
**Update removeInstance:**
|
||||
|
||||
- Destroy SDK client before removing
|
||||
- Call sdkManager.destroyClient(port)
|
||||
|
||||
### 3. Create Session Store
|
||||
|
||||
**src/stores/sessions.ts:**
|
||||
|
||||
**State structure:**
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionStore {
|
||||
// Sessions grouped by instance
|
||||
sessions: Map<string, Map<string, Session>>
|
||||
|
||||
// Active session per instance
|
||||
activeSessionId: Map<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
**Core actions:**
|
||||
|
||||
```typescript
|
||||
// Fetch all sessions for an instance
|
||||
async function fetchSessions(instanceId: string): Promise<void>
|
||||
|
||||
// Create new session
|
||||
async function createSession(instanceId: string, agent: string): Promise<Session>
|
||||
|
||||
// Delete session
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void>
|
||||
|
||||
// Set active session
|
||||
function setActiveSession(instanceId: string, sessionId: string): void
|
||||
|
||||
// Get active session
|
||||
function getActiveSession(instanceId: string): Session | null
|
||||
|
||||
// Get all sessions for instance
|
||||
function getSessions(instanceId: string): Session[]
|
||||
```
|
||||
|
||||
### 4. Implement Session Fetching
|
||||
|
||||
**fetchSessions implementation:**
|
||||
|
||||
```typescript
|
||||
async function fetchSessions(instanceId: string) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
// Convert API response to Session objects
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentId || null,
|
||||
agent: "", // Will be populated from messages
|
||||
model: { providerId: "", modelId: "" },
|
||||
time: {
|
||||
created: apiSession.time.created,
|
||||
updated: apiSession.time.updated,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sessions.set(instanceId, sessionMap)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Implement Session Creation
|
||||
|
||||
**createSession implementation:**
|
||||
|
||||
```typescript
|
||||
async function createSession(instanceId: string, agent: string): Promise<Session> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.create({
|
||||
// OpenCode API might need specific params
|
||||
})
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
instanceId,
|
||||
title: "New Session",
|
||||
parentId: null,
|
||||
agent,
|
||||
model: { providerId: "", modelId: "" },
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Add to store
|
||||
const instanceSessions = sessions.get(instanceId) || new Map()
|
||||
instanceSessions.set(session.id, session)
|
||||
sessions.set(instanceId, instanceSessions)
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Implement Session Deletion
|
||||
|
||||
**deleteSession implementation:**
|
||||
|
||||
```typescript
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
// Remove from store
|
||||
const instanceSessions = sessions.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
}
|
||||
|
||||
// Clear active if it was active
|
||||
if (activeSessionId.get(instanceId) === sessionId) {
|
||||
activeSessionId.delete(instanceId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Implement Agent & Model Fetching
|
||||
|
||||
**Fetch available agents:**
|
||||
|
||||
```typescript
|
||||
interface Agent {
|
||||
name: string
|
||||
description: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<Agent[]> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.agent.list()
|
||||
return response.data.filter((agent) => agent.mode !== "subagent")
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agents:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fetch available models:**
|
||||
|
||||
```typescript
|
||||
interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Model[]
|
||||
}
|
||||
|
||||
interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
}
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<Provider[]> {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instance.client.config.providers()
|
||||
return response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: Object.entries(provider.models).map(([id, model]) => ({
|
||||
id,
|
||||
name: model.name,
|
||||
providerId: provider.id,
|
||||
})),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Add Error Handling
|
||||
|
||||
**Network error handling:**
|
||||
|
||||
```typescript
|
||||
function handleSDKError(error: any): string {
|
||||
if (error.code === "ECONNREFUSED") {
|
||||
return "Cannot connect to server. Is it running?"
|
||||
}
|
||||
if (error.code === "ETIMEDOUT") {
|
||||
return "Request timed out. Please try again."
|
||||
}
|
||||
if (error.response?.status === 404) {
|
||||
return "Resource not found"
|
||||
}
|
||||
if (error.response?.status === 500) {
|
||||
return "Server error. Check logs."
|
||||
}
|
||||
return error.message || "Unknown error occurred"
|
||||
}
|
||||
```
|
||||
|
||||
**Retry logic (for transient failures):**
|
||||
|
||||
```typescript
|
||||
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3, delay = 1000): Promise<T> {
|
||||
let lastError
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Add Loading States
|
||||
|
||||
**Track loading states:**
|
||||
|
||||
```typescript
|
||||
interface LoadingState {
|
||||
fetchingSessions: Map<string, boolean>
|
||||
creatingSession: Map<string, boolean>
|
||||
deletingSession: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
const loading: LoadingState = {
|
||||
fetchingSessions: new Map(),
|
||||
creatingSession: new Map(),
|
||||
deletingSession: new Map(),
|
||||
}
|
||||
```
|
||||
|
||||
**Use in actions:**
|
||||
|
||||
```typescript
|
||||
async function fetchSessions(instanceId: string) {
|
||||
loading.fetchingSessions.set(instanceId, true)
|
||||
try {
|
||||
// ... fetch logic
|
||||
} finally {
|
||||
loading.fetchingSessions.set(instanceId, false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Wire Up to Instance Creation
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
**After server ready:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
// ... spawn server ...
|
||||
|
||||
// Create SDK client
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
// Update instance
|
||||
instances.set(id, {
|
||||
...instances.get(id)!,
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
})
|
||||
|
||||
// Fetch initial data
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
// Don't fail instance creation, just log
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
### 11. Add Type Safety
|
||||
|
||||
**src/types/session.ts:**
|
||||
|
||||
```typescript
|
||||
export interface Session {
|
||||
id: string
|
||||
instanceId: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
agent: string
|
||||
model: {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
name: string
|
||||
description: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Model[]
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Sessions fetched automatically
|
||||
2. Console shows session list
|
||||
3. Create new session → Appears in list
|
||||
4. Delete session → Removed from list
|
||||
5. Network fails → Error message shown
|
||||
6. Server not running → Graceful error
|
||||
|
||||
**Error Cases:**
|
||||
|
||||
- Server not responding (ECONNREFUSED)
|
||||
- Request timeout
|
||||
- 404 on session endpoint
|
||||
- 500 server error
|
||||
- Invalid session ID
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- No sessions exist (empty list)
|
||||
- Many sessions (100+)
|
||||
- Session with very long title
|
||||
- Parent-child session relationships
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 005 (needs session data)
|
||||
- **Blocked by:** Task 003 (needs running server)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep SDK calls isolated in store actions
|
||||
- All SDK calls should have error handling
|
||||
- Consider caching to reduce API calls
|
||||
- Log all API calls for debugging
|
||||
- Handle slow connections gracefully
|
||||
333
tasks/done/005-session-picker-modal.md
Normal file
333
tasks/done/005-session-picker-modal.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Task 005: Session Picker Modal
|
||||
|
||||
## Goal
|
||||
|
||||
Create the session picker modal that appears when an instance starts, allowing users to resume an existing session or create a new one.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 004 completed (SDK integration, session fetching)
|
||||
- Understanding of modal/dialog patterns
|
||||
- Kobalte UI primitives knowledge
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Modal appears after instance becomes ready
|
||||
- [ ] Displays list of existing sessions
|
||||
- [ ] Shows session metadata (title, timestamp)
|
||||
- [ ] Allows creating new session with agent selection
|
||||
- [ ] Can close modal (cancels instance creation)
|
||||
- [ ] Keyboard navigation works (up/down, enter)
|
||||
- [ ] Properly styled and accessible
|
||||
- [ ] Loading states during fetch
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Session Picker Component
|
||||
|
||||
**src/components/session-picker.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionPickerProps {
|
||||
instanceId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSessionSelect: (sessionId: string) => void
|
||||
onNewSession: (agent: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Modal backdrop (semi-transparent overlay)
|
||||
- Modal dialog (centered card)
|
||||
- Header: "OpenCode • {folder}"
|
||||
- Section 1: Resume session list
|
||||
- Separator: "or"
|
||||
- Section 2: Create new session
|
||||
- Footer: Cancel button
|
||||
|
||||
### 2. Use Kobalte Dialog
|
||||
|
||||
**Implementation approach:**
|
||||
|
||||
```typescript
|
||||
import { Dialog } from '@kobalte/core'
|
||||
|
||||
<Dialog.Root open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay />
|
||||
<Dialog.Content>
|
||||
{/* Modal content */}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Overlay: Dark background, 50% opacity
|
||||
- Content: White card, max-width 500px, centered
|
||||
- Padding: 24px
|
||||
- Border radius: 8px
|
||||
- Shadow: Large elevation
|
||||
|
||||
### 3. Create Session List Section
|
||||
|
||||
**Resume Section:**
|
||||
|
||||
- Header: "Resume a session:"
|
||||
- List of sessions (max 10 recent)
|
||||
- Each item shows:
|
||||
- Title (truncated at 50 chars)
|
||||
- Relative timestamp ("2h ago")
|
||||
- Hover state
|
||||
- Active selection state
|
||||
|
||||
**Session Item Component:**
|
||||
|
||||
```typescript
|
||||
interface SessionItemProps {
|
||||
session: Session
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Empty state:**
|
||||
|
||||
- Show when no sessions exist
|
||||
- Text: "No previous sessions"
|
||||
- Muted styling
|
||||
|
||||
**Scrollable:**
|
||||
|
||||
- If >5 sessions, add scroll
|
||||
- Max height: 300px
|
||||
|
||||
### 4. Create New Session Section
|
||||
|
||||
**Structure:**
|
||||
|
||||
- Header: "Start new session:"
|
||||
- Agent selector dropdown
|
||||
- "Start" button
|
||||
|
||||
**Agent Selector:**
|
||||
|
||||
- Dropdown using Kobalte Select
|
||||
- Shows agent name
|
||||
- Grouped by category if applicable
|
||||
- Default: "Build" agent
|
||||
|
||||
**Start Button:**
|
||||
|
||||
- Primary button style
|
||||
- Click triggers onNewSession callback
|
||||
- Disabled while creating
|
||||
|
||||
### 5. Add Loading States
|
||||
|
||||
**While fetching sessions:**
|
||||
|
||||
- Show skeleton list (3-4 placeholder items)
|
||||
- Shimmer animation
|
||||
|
||||
**While fetching agents:**
|
||||
|
||||
- Agent dropdown shows "Loading..."
|
||||
- Disabled state
|
||||
|
||||
**While creating session:**
|
||||
|
||||
- Start button shows spinner
|
||||
- Disabled state
|
||||
- Text changes to "Creating..."
|
||||
|
||||
### 6. Wire Up to Instance Store
|
||||
|
||||
**Show modal after instance ready:**
|
||||
|
||||
**src/stores/ui.ts additions:**
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
sessionPickerInstance: string | null
|
||||
}
|
||||
|
||||
function showSessionPicker(instanceId: string) {
|
||||
sessionPickerInstance = instanceId
|
||||
}
|
||||
|
||||
function hideSessionPicker() {
|
||||
sessionPickerInstance = null
|
||||
}
|
||||
```
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
```typescript
|
||||
async function createInstance(folder: string) {
|
||||
// ... spawn and connect ...
|
||||
|
||||
// Show session picker
|
||||
showSessionPicker(id)
|
||||
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Handle Session Selection
|
||||
|
||||
**Resume session:**
|
||||
|
||||
```typescript
|
||||
function handleSessionSelect(sessionId: string) {
|
||||
setActiveSession(instanceId, sessionId)
|
||||
hideSessionPicker()
|
||||
|
||||
// Will trigger session display in Task 006
|
||||
}
|
||||
```
|
||||
|
||||
**Create new session:**
|
||||
|
||||
```typescript
|
||||
async function handleNewSession(agent: string) {
|
||||
try {
|
||||
const session = await createSession(instanceId, agent)
|
||||
setActiveSession(instanceId, session.id)
|
||||
hideSessionPicker()
|
||||
} catch (error) {
|
||||
// Show error toast (Task 010)
|
||||
console.error("Failed to create session:", error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Handle Cancel
|
||||
|
||||
**Close modal:**
|
||||
|
||||
```typescript
|
||||
function handleClose() {
|
||||
// Remove instance since user cancelled
|
||||
await removeInstance(instanceId)
|
||||
hideSessionPicker()
|
||||
}
|
||||
```
|
||||
|
||||
**Confirmation if needed:**
|
||||
|
||||
- If server already started, ask "Stop server?"
|
||||
- Otherwise, just close
|
||||
|
||||
### 9. Add Keyboard Navigation
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
- Up/Down: Navigate session list
|
||||
- Enter: Select highlighted session
|
||||
- Escape: Close modal (cancel)
|
||||
- Tab: Cycle through sections
|
||||
|
||||
**Implement focus management:**
|
||||
|
||||
- Auto-focus first session on open
|
||||
- Trap focus within modal
|
||||
- Restore focus on close
|
||||
|
||||
### 10. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
- `role="dialog"`
|
||||
- `aria-labelledby="dialog-title"`
|
||||
- `aria-describedby="dialog-description"`
|
||||
- `aria-modal="true"`
|
||||
|
||||
**Screen reader support:**
|
||||
|
||||
- Announce "X sessions available"
|
||||
- Announce selection changes
|
||||
- Clear focus indicators
|
||||
|
||||
### 11. Style Refinements
|
||||
|
||||
**Light/Dark mode:**
|
||||
|
||||
- Test in both themes
|
||||
- Ensure contrast meets WCAG AA
|
||||
- Use CSS variables for colors
|
||||
|
||||
**Responsive:**
|
||||
|
||||
- Works at minimum window size
|
||||
- Mobile-friendly (future web version)
|
||||
- Scales text appropriately
|
||||
|
||||
**Animations:**
|
||||
|
||||
- Fade in backdrop (200ms)
|
||||
- Scale in content (200ms)
|
||||
- Smooth transitions on hover
|
||||
|
||||
### 12. Update App Component
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
**Render session picker:**
|
||||
|
||||
```typescript
|
||||
<Show when={ui.sessionPickerInstance}>
|
||||
{(instanceId) => (
|
||||
<SessionPicker
|
||||
instanceId={instanceId()}
|
||||
open={true}
|
||||
onClose={() => ui.hideSessionPicker()}
|
||||
onSessionSelect={(id) => handleSessionSelect(instanceId(), id)}
|
||||
onNewSession={(agent) => handleNewSession(instanceId(), agent)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Modal appears
|
||||
2. Shows session list if sessions exist
|
||||
3. Shows empty state if no sessions
|
||||
4. Click session → Modal closes, session activates
|
||||
5. Select agent, click Start → New session created
|
||||
6. Press Escape → Modal closes, instance removed
|
||||
7. Keyboard navigation works
|
||||
8. Screen reader announces content
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- No sessions + no agents (error state)
|
||||
- Very long session titles (truncate)
|
||||
- Many sessions (scroll works)
|
||||
- Create session fails (error shown)
|
||||
- Slow network (loading states)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 006 (needs active session)
|
||||
- **Blocked by:** Task 004 (needs session data)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep modal simple and focused
|
||||
- Clear call-to-action
|
||||
- Don't overwhelm with options
|
||||
- Loading states crucial for UX
|
||||
- Consider adding search if >20 sessions (future)
|
||||
591
tasks/done/006-instance-session-tabs.md
Normal file
591
tasks/done/006-instance-session-tabs.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Task 006: Instance & Session Tabs
|
||||
|
||||
## Goal
|
||||
|
||||
Create the two-level tab navigation system: instance tabs (Level 1) and session tabs (Level 2) that allow users to switch between projects and conversations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 005 completed (Session picker modal, active session selection)
|
||||
- Understanding of tab navigation patterns
|
||||
- Familiarity with SolidJS For/Show components
|
||||
- Knowledge of keyboard accessibility
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Instance tabs render at top level
|
||||
- [ ] Session tabs render below instance tabs for active instance
|
||||
- [ ] Can switch between instance tabs
|
||||
- [ ] Can switch between session tabs within an instance
|
||||
- [ ] Active tab is visually highlighted
|
||||
- [ ] Tab labels show appropriate text (folder name, session title)
|
||||
- [ ] Close buttons work on tabs (with confirmation)
|
||||
- [ ] "+" button creates new instance/session
|
||||
- [ ] Keyboard navigation works (Cmd/Ctrl+1-9 for tabs)
|
||||
- [ ] Tabs scroll horizontally when many exist
|
||||
- [ ] Properly styled and accessible
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create Instance Tabs Component
|
||||
|
||||
**src/components/instance-tabs.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
activeInstanceId: string | null
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<div class="instance-tabs">
|
||||
<div class="tabs-container">
|
||||
<For each={Array.from(instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === activeInstanceId}
|
||||
onSelect={() => onSelect(id)}
|
||||
onClose={() => onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button class="new-tab-button" onClick={onNew}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Horizontal layout
|
||||
- Background: Secondary background color
|
||||
- Border bottom: 1px solid border color
|
||||
- Height: 40px
|
||||
- Padding: 0 8px
|
||||
- Overflow-x: auto (for many tabs)
|
||||
|
||||
### 2. Create Instance Tab Item Component
|
||||
|
||||
**src/components/instance-tab.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<button class={`instance-tab ${active ? "active" : ""}`} onClick={onSelect}>
|
||||
<span class="tab-icon">📁</span>
|
||||
<span class="tab-label">{formatFolderName(instance.folder)}</span>
|
||||
<button
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Display: inline-flex
|
||||
- Align items center
|
||||
- Gap: 8px
|
||||
- Padding: 8px 12px
|
||||
- Border radius: 6px 6px 0 0
|
||||
- Max width: 200px
|
||||
- Truncate text with ellipsis
|
||||
- Active: Background accent color
|
||||
- Inactive: Transparent background
|
||||
- Hover: Light background
|
||||
|
||||
**Folder Name Formatting:**
|
||||
|
||||
```typescript
|
||||
function formatFolderName(path: string): string {
|
||||
const name = path.split("/").pop() || path
|
||||
return `~/${name}`
|
||||
}
|
||||
```
|
||||
|
||||
**Handle Duplicates:**
|
||||
|
||||
- If multiple instances have same folder name, add counter
|
||||
- Example: `~/project`, `~/project (2)`, `~/project (3)`
|
||||
|
||||
### 3. Create Session Tabs Component
|
||||
|
||||
**src/components/session-tabs.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabsProps {
|
||||
instanceId: string
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
onSelect: (sessionId: string) => void
|
||||
onClose: (sessionId: string) => void
|
||||
onNew: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<div class="session-tabs">
|
||||
<div class="tabs-container">
|
||||
<For each={Array.from(sessions.entries())}>
|
||||
{([id, session]) => (
|
||||
<SessionTab
|
||||
session={session}
|
||||
active={id === activeSessionId}
|
||||
onSelect={() => onSelect(id)}
|
||||
onClose={() => onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SessionTab special="logs" active={activeSessionId === "logs"} onSelect={() => onSelect("logs")} />
|
||||
<button class="new-tab-button" onClick={onNew}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Similar to instance tabs but smaller
|
||||
- Height: 36px
|
||||
- Font size: 13px
|
||||
- Less prominent than instance tabs
|
||||
|
||||
### 4. Create Session Tab Item Component
|
||||
|
||||
**src/components/session-tab.tsx:**
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface SessionTabProps {
|
||||
session?: Session
|
||||
special?: "logs"
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Structure:**
|
||||
|
||||
```tsx
|
||||
<button class={`session-tab ${active ? "active" : ""} ${special ? "special" : ""}`} onClick={onSelect}>
|
||||
<span class="tab-label">{special === "logs" ? "Logs" : session?.title || "Untitled"}</span>
|
||||
<Show when={!special && onClose}>
|
||||
<button
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Show>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Max width: 150px
|
||||
- Truncate with ellipsis
|
||||
- Active: Underline or bold text
|
||||
- Logs tab: Slightly different color/icon
|
||||
|
||||
### 5. Add Tab State Management
|
||||
|
||||
**src/stores/ui.ts updates:**
|
||||
|
||||
```typescript
|
||||
interface UIState {
|
||||
instanceTabOrder: string[]
|
||||
sessionTabOrder: Map<string, string[]>
|
||||
|
||||
reorderInstanceTabs: (newOrder: string[]) => void
|
||||
reorderSessionTabs: (instanceId: string, newOrder: string[]) => void
|
||||
}
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
|
||||
function reorderInstanceTabs(newOrder: string[]) {
|
||||
setInstanceTabOrder(newOrder)
|
||||
}
|
||||
|
||||
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
|
||||
setSessionTabOrder((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, newOrder)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wire Up Tab Selection
|
||||
|
||||
**src/stores/instances.ts updates:**
|
||||
|
||||
```typescript
|
||||
function setActiveInstance(id: string) {
|
||||
activeInstanceId = id
|
||||
|
||||
// Auto-select first session or show session picker
|
||||
const instance = instances.get(id)
|
||||
if (instance) {
|
||||
const sessions = Array.from(instance.sessions.values())
|
||||
if (sessions.length > 0 && !instance.activeSessionId) {
|
||||
instance.activeSessionId = sessions[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveSession(instanceId: string, sessionId: string) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (instance) {
|
||||
instance.activeSessionId = sessionId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Handle Tab Close Actions
|
||||
|
||||
**Close Instance Tab:**
|
||||
|
||||
```typescript
|
||||
async function handleCloseInstance(instanceId: string) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: "Stop OpenCode instance?",
|
||||
message: `This will stop the server for ${instance.folder}`,
|
||||
confirmText: "Stop Instance",
|
||||
destructive: true,
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await removeInstance(instanceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Close Session Tab:**
|
||||
|
||||
```typescript
|
||||
async function handleCloseSession(instanceId: string, sessionId: string) {
|
||||
const session = getInstance(instanceId)?.sessions.get(sessionId)
|
||||
|
||||
if (session && session.messages.length > 0) {
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: "Delete session?",
|
||||
message: `This will permanently delete "${session.title}"`,
|
||||
confirmText: "Delete",
|
||||
destructive: true,
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
await deleteSession(instanceId, sessionId)
|
||||
|
||||
// Switch to another session
|
||||
const instance = getInstance(instanceId)
|
||||
const remainingSessions = Array.from(instance.sessions.values())
|
||||
if (remainingSessions.length > 0) {
|
||||
setActiveSession(instanceId, remainingSessions[0].id)
|
||||
} else {
|
||||
// Show session picker
|
||||
showSessionPicker(instanceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Handle New Tab Buttons
|
||||
|
||||
**New Instance:**
|
||||
|
||||
```typescript
|
||||
async function handleNewInstance() {
|
||||
const folder = await window.electronAPI.selectFolder()
|
||||
if (folder) {
|
||||
await createInstance(folder)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Session:**
|
||||
|
||||
```typescript
|
||||
async function handleNewSession(instanceId: string) {
|
||||
// For now, use default agent
|
||||
// Later (Task 011) will show agent selector
|
||||
const session = await createSession(instanceId, "build")
|
||||
setActiveSession(instanceId, session.id)
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Update App Layout
|
||||
|
||||
**src/App.tsx:**
|
||||
|
||||
```tsx
|
||||
<div class="app">
|
||||
<Show when={instances.size > 0} fallback={<EmptyState />}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstance}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstance}
|
||||
/>
|
||||
|
||||
<Show when={activeInstance()}>
|
||||
{(instance) => (
|
||||
<>
|
||||
<SessionTabs
|
||||
instanceId={instance().id}
|
||||
sessions={instance().sessions}
|
||||
activeSessionId={instance().activeSessionId}
|
||||
onSelect={(id) => setActiveSession(instance().id, id)}
|
||||
onClose={(id) => handleCloseSession(instance().id, id)}
|
||||
onNew={() => handleNewSession(instance().id)}
|
||||
/>
|
||||
|
||||
<div class="content-area">
|
||||
{/* Message stream and input will go here in Task 007 */}
|
||||
<Show when={instance().activeSessionId === "logs"}>
|
||||
<LogsView logs={instance().logs} />
|
||||
</Show>
|
||||
<Show when={instance().activeSessionId !== "logs"}>
|
||||
<div class="placeholder">Session content will appear here (Task 007)</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 10. Add Keyboard Shortcuts
|
||||
|
||||
**Keyboard navigation:**
|
||||
|
||||
```typescript
|
||||
// src/lib/keyboard.ts
|
||||
|
||||
export function setupTabKeyboardShortcuts() {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
// Cmd/Ctrl + 1-9: Switch instance tabs
|
||||
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault()
|
||||
const index = parseInt(e.key) - 1
|
||||
const instances = Array.from(instanceStore.instances.keys())
|
||||
if (instances[index]) {
|
||||
setActiveInstance(instances[index])
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + N: New instance
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
|
||||
e.preventDefault()
|
||||
handleNewInstance()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + T: New session
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
|
||||
e.preventDefault()
|
||||
if (activeInstanceId()) {
|
||||
handleNewSession(activeInstanceId()!)
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W: Close current tab
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
|
||||
e.preventDefault()
|
||||
const instanceId = activeInstanceId()
|
||||
const instance = getInstance(instanceId)
|
||||
if (instance?.activeSessionId && instance.activeSessionId !== "logs") {
|
||||
handleCloseSession(instanceId!, instance.activeSessionId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Call in main.tsx:**
|
||||
|
||||
```typescript
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
|
||||
onMount(() => {
|
||||
setupTabKeyboardShortcuts()
|
||||
})
|
||||
```
|
||||
|
||||
### 11. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
```tsx
|
||||
<div role="tablist" aria-label="Instance tabs">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={`instance-panel-${instance.id}`}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`instance-panel-${instance.id}`}
|
||||
aria-labelledby={`instance-tab-${instance.id}`}
|
||||
>
|
||||
{/* Session tabs */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Focus management:**
|
||||
|
||||
- Tab key cycles through tabs
|
||||
- Arrow keys navigate within tab list
|
||||
- Focus indicators visible
|
||||
- Skip links for screen readers
|
||||
|
||||
### 12. Style Refinements
|
||||
|
||||
**Horizontal scroll:**
|
||||
|
||||
```css
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.tabs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
**Tab animations:**
|
||||
|
||||
```css
|
||||
.instance-tab,
|
||||
.session-tab {
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.instance-tab:hover,
|
||||
.session-tab:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
|
||||
.instance-tab.active,
|
||||
.session-tab.active {
|
||||
background-color: var(--active-background);
|
||||
}
|
||||
```
|
||||
|
||||
**Close button styling:**
|
||||
|
||||
```css
|
||||
.tab-close {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.instance-tab:hover .tab-close,
|
||||
.session-tab:hover .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background-color: var(--danger-background);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Create instance → Instance tab appears
|
||||
2. Click instance tab → Switches active instance
|
||||
3. Session tabs appear below active instance
|
||||
4. Click session tab → Switches active session
|
||||
5. Click "+" on instance tabs → Opens folder picker
|
||||
6. Click "+" on session tabs → Creates new session
|
||||
7. Click close on instance tab → Shows confirmation, closes
|
||||
8. Click close on session tab → Closes session
|
||||
9. Cmd/Ctrl+1 switches to first instance
|
||||
10. Cmd/Ctrl+N opens new instance
|
||||
11. Cmd/Ctrl+T creates new session
|
||||
12. Cmd/Ctrl+W closes active session
|
||||
13. Tabs scroll when many exist
|
||||
14. Logs tab always visible and non-closable
|
||||
15. Tab labels truncate long names
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Only one instance (no scrolling needed)
|
||||
- Many instances (>10, horizontal scroll)
|
||||
- No sessions in instance (only Logs tab visible)
|
||||
- Duplicate folder names (counter added)
|
||||
- Very long folder/session names (ellipsis)
|
||||
- Close last session (session picker appears)
|
||||
- Switch instance while session is streaming
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 007 (needs tab structure to display messages)
|
||||
- **Blocked by:** Task 005 (needs session selection to work)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep tab design clean and minimal
|
||||
- Don't over-engineer tab reordering (can add later)
|
||||
- Focus on functionality over fancy animations
|
||||
- Ensure keyboard accessibility from the start
|
||||
- Tab state will persist in Task 017
|
||||
- Context menus for tabs can be added in Task 026
|
||||
812
tasks/done/007-message-display.md
Normal file
812
tasks/done/007-message-display.md
Normal file
@@ -0,0 +1,812 @@
|
||||
# Task 007: Message Display
|
||||
|
||||
## Goal
|
||||
|
||||
Create the message display component that renders user and assistant messages in a scrollable stream, showing message content, tool calls, and streaming states.
|
||||
|
||||
> Note: This legacy task predates `message-stream-v2` and the normalized message store; the new implementation lives under `packages/ui/src/components/message-stream-v2.tsx`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 006 completed (Tab navigation in place)
|
||||
- Understanding of message part structure from OpenCode SDK
|
||||
- Familiarity with markdown rendering
|
||||
- Knowledge of SolidJS For/Show components
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Messages render in chronological order
|
||||
- [ ] User messages display with correct styling
|
||||
- [ ] Assistant messages display with agent label
|
||||
- [ ] Text content renders properly
|
||||
- [ ] Tool calls display inline with collapse/expand
|
||||
- [ ] Auto-scroll to bottom on new messages
|
||||
- [ ] Manual scroll up disables auto-scroll
|
||||
- [ ] "Scroll to bottom" button appears when scrolled up
|
||||
- [ ] Empty state shows when no messages
|
||||
- [ ] Loading state shows when fetching messages
|
||||
- [ ] Timestamps display for each message
|
||||
- [ ] Messages are accessible and keyboard-navigable
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Define Message Types
|
||||
|
||||
**src/types/message.ts:**
|
||||
|
||||
```typescript
|
||||
export interface Message {
|
||||
id: string
|
||||
sessionId: string
|
||||
type: "user" | "assistant"
|
||||
parts: MessagePart[]
|
||||
timestamp: number
|
||||
status: "sending" | "sent" | "streaming" | "complete" | "error"
|
||||
}
|
||||
|
||||
export type MessagePart = TextPart | ToolCallPart | ToolResultPart | ErrorPart
|
||||
|
||||
export interface TextPart {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ToolCallPart {
|
||||
type: "tool_call"
|
||||
id: string
|
||||
tool: string
|
||||
input: any
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
}
|
||||
|
||||
export interface ToolResultPart {
|
||||
type: "tool_result"
|
||||
toolCallId: string
|
||||
output: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ErrorPart {
|
||||
type: "error"
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create Message Stream Component
|
||||
|
||||
**src/components/message-stream.tsx:**
|
||||
|
||||
```typescript
|
||||
import { For, Show, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Message } from "../types/message"
|
||||
import MessageItem from "./message-item"
|
||||
|
||||
interface MessageStreamProps {
|
||||
sessionId: string
|
||||
messages: Message[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function MessageStream(props: MessageStreamProps) {
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||
|
||||
function scrollToBottom() {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = containerRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
setShowScrollButton(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
setShowScrollButton(!isAtBottom)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (autoScroll()) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
const messagesLength = () => props.messages.length
|
||||
createEffect(() => {
|
||||
messagesLength() // Track changes
|
||||
if (autoScroll()) {
|
||||
setTimeout(scrollToBottom, 0)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="message-stream"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={!props.loading && props.messages.length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or try:</p>
|
||||
<ul>
|
||||
<li><code>/init-project</code></li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>Attach files with <code>@</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.messages}>
|
||||
{(message) => (
|
||||
<MessageItem message={message} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollButton()}>
|
||||
<button
|
||||
class="scroll-to-bottom"
|
||||
onClick={scrollToBottom}
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Message Item Component
|
||||
|
||||
**src/components/message-item.tsx:**
|
||||
|
||||
```typescript
|
||||
import { For, Show } from "solid-js"
|
||||
import { Message } from "../types/message"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
const timestamp = () => {
|
||||
const date = new Date(props.message.timestamp)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`message-item ${isUser() ? "user" : "assistant"}`}>
|
||||
<div class="message-header">
|
||||
<span class="message-sender">
|
||||
{isUser() ? "You" : "Assistant"}
|
||||
</span>
|
||||
<span class="message-timestamp">{timestamp()}</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
<For each={props.message.parts}>
|
||||
{(part) => <MessagePart part={part} />}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">
|
||||
⚠ Message failed to send
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Message Part Component
|
||||
|
||||
**src/components/message-part.tsx:**
|
||||
|
||||
```typescript
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import { MessagePart as MessagePartType } from "../types/message"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType
|
||||
}
|
||||
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.type === "text"}>
|
||||
<div class="message-text">
|
||||
{(props.part as any).text}
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={props.part.type === "tool_call"}>
|
||||
<ToolCall toolCall={props.part as any} />
|
||||
</Match>
|
||||
|
||||
<Match when={props.part.type === "error"}>
|
||||
<div class="message-error-part">
|
||||
⚠ {(props.part as any).message}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Create Tool Call Component
|
||||
|
||||
**src/components/tool-call.tsx:**
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { ToolCallPart } from "../types/message"
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: ToolCallPart
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const statusIcon = () => {
|
||||
switch (props.toolCall.status) {
|
||||
case "pending":
|
||||
return "⏳"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "success":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = () => {
|
||||
return `tool-call-status-${props.toolCall.status}`
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded())
|
||||
}
|
||||
|
||||
function formatToolSummary() {
|
||||
// Create a brief summary of the tool call
|
||||
const { tool, input } = props.toolCall
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return `bash: ${input.command}`
|
||||
case "edit":
|
||||
return `edit ${input.filePath}`
|
||||
case "read":
|
||||
return `read ${input.filePath}`
|
||||
case "write":
|
||||
return `write ${input.filePath}`
|
||||
default:
|
||||
return `${tool}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`tool-call ${statusClass()}`}>
|
||||
<button
|
||||
class="tool-call-header"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={expanded()}
|
||||
>
|
||||
<span class="tool-call-icon">
|
||||
{expanded() ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-summary">
|
||||
{formatToolSummary()}
|
||||
</span>
|
||||
<span class="tool-call-status">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="tool-call-details">
|
||||
<div class="tool-call-section">
|
||||
<h4>Input:</h4>
|
||||
<pre><code>{JSON.stringify(props.toolCall.input, null, 2)}</code></pre>
|
||||
</div>
|
||||
|
||||
<Show when={props.toolCall.status === "success" || props.toolCall.status === "error"}>
|
||||
<div class="tool-call-section">
|
||||
<h4>Output:</h4>
|
||||
<pre><code>{formatToolOutput()}</code></pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
function formatToolOutput() {
|
||||
// This will be enhanced in later tasks
|
||||
// For now, just stringify
|
||||
return "Output will be displayed here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Add Message Store Integration
|
||||
|
||||
**src/stores/sessions.ts updates:**
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
// ... existing fields
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string) {
|
||||
const instance = getInstance(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
try {
|
||||
// Fetch messages from SDK
|
||||
const response = await instance.client.session.getMessages(sessionId)
|
||||
|
||||
// Update session with messages
|
||||
const session = instance.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.messages = response.messages.map(transformMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function transformMessage(apiMessage: any): Message {
|
||||
return {
|
||||
id: apiMessage.id,
|
||||
sessionId: apiMessage.sessionId,
|
||||
type: apiMessage.type,
|
||||
parts: apiMessage.parts || [],
|
||||
timestamp: apiMessage.timestamp || Date.now(),
|
||||
status: "complete",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Update App to Show Messages
|
||||
|
||||
**src/App.tsx updates:**
|
||||
|
||||
```tsx
|
||||
<Show when={instance().activeSessionId !== "logs"}>
|
||||
{() => {
|
||||
const session = instance().sessions.get(instance().activeSessionId!)
|
||||
|
||||
return (
|
||||
<Show when={session} fallback={<div>Session not found</div>}>
|
||||
{(s) => <MessageStream sessionId={s().id} messages={s().messages} loading={false} />}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
```
|
||||
|
||||
### 8. Add Styling
|
||||
|
||||
**src/components/message-stream.css:**
|
||||
|
||||
```css
|
||||
.message-stream-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message-item.user {
|
||||
align-self: flex-end;
|
||||
background-color: var(--user-message-bg);
|
||||
}
|
||||
|
||||
.message-item.assistant {
|
||||
align-self: flex-start;
|
||||
background-color: var(--assistant-message-bg);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
background-color: var(--secondary-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-header:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--success-color);
|
||||
}
|
||||
|
||||
.tool-call-status-error {
|
||||
border-left: 3px solid var(--error-color);
|
||||
}
|
||||
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.tool-call-details {
|
||||
padding: 12px;
|
||||
background-color: var(--code-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-section pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--background);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tool-call-section code {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.scroll-to-bottom:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Add CSS Variables
|
||||
|
||||
**src/index.css updates:**
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Message colors */
|
||||
--user-message-bg: #e3f2fd;
|
||||
--assistant-message-bg: #f5f5f5;
|
||||
|
||||
/* Status colors */
|
||||
--success-color: #4caf50;
|
||||
--error-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
|
||||
/* Code colors */
|
||||
--code-bg: #f8f8f8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--user-message-bg: #1e3a5f;
|
||||
--assistant-message-bg: #2a2a2a;
|
||||
--code-bg: #1a1a1a;
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Load Messages on Session Switch
|
||||
|
||||
**src/hooks/use-session.ts:**
|
||||
|
||||
```typescript
|
||||
import { createEffect } from "solid-js"
|
||||
|
||||
export function useSession(instanceId: string, sessionId: string) {
|
||||
createEffect(() => {
|
||||
// Load messages when session becomes active
|
||||
if (sessionId && sessionId !== "logs") {
|
||||
loadMessages(instanceId, sessionId).catch(console.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Use in App.tsx:**
|
||||
|
||||
```tsx
|
||||
<Show when={session}>
|
||||
{(s) => {
|
||||
useSession(instance().id, s().id)
|
||||
|
||||
return <MessageStream sessionId={s().id} messages={s().messages} loading={false} />
|
||||
}}
|
||||
</Show>
|
||||
```
|
||||
|
||||
### 11. Add Accessibility
|
||||
|
||||
**ARIA attributes:**
|
||||
|
||||
```tsx
|
||||
<div
|
||||
class="message-stream"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Message history"
|
||||
>
|
||||
{/* Messages */}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="message-item"
|
||||
role="article"
|
||||
aria-label={`${isUser() ? "Your" : "Assistant"} message at ${timestamp()}`}
|
||||
>
|
||||
{/* Message content */}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Keyboard navigation:**
|
||||
|
||||
- Messages should be accessible via Tab key
|
||||
- Tool calls can be expanded with Enter/Space
|
||||
- Screen readers announce new messages
|
||||
|
||||
### 12. Handle Long Messages
|
||||
|
||||
**Text wrapping:**
|
||||
|
||||
```css
|
||||
.message-text {
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
```
|
||||
|
||||
**Code blocks (for now, just basic):**
|
||||
|
||||
```css
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
**Manual Tests:**
|
||||
|
||||
1. Empty session shows empty state
|
||||
2. Messages load when switching sessions
|
||||
3. User messages appear on right
|
||||
4. Assistant messages appear on left
|
||||
5. Timestamps display correctly
|
||||
6. Tool calls appear inline
|
||||
7. Tool calls expand/collapse on click
|
||||
8. Auto-scroll works for new messages
|
||||
9. Manual scroll up disables auto-scroll
|
||||
10. Scroll to bottom button appears/works
|
||||
11. Long messages wrap correctly
|
||||
12. Multiple messages display properly
|
||||
13. Messages are keyboard accessible
|
||||
|
||||
**Edge Cases:**
|
||||
|
||||
- Session with 1 message
|
||||
- Session with 100+ messages
|
||||
- Messages with very long text
|
||||
- Messages with no parts
|
||||
- Tool calls with large output
|
||||
- Rapid message updates
|
||||
- Switching sessions while loading
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocks:** Task 008 (SSE will update these messages in real-time)
|
||||
- **Blocked by:** Task 006 (needs tab structure)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep styling simple for now - markdown rendering comes in Task 012
|
||||
- Tool output formatting will be enhanced in Task 010
|
||||
- Focus on basic text display and structure
|
||||
- Don't optimize for virtual scrolling yet (MVP principle)
|
||||
- Message actions (copy, edit, etc.) come in Task 026
|
||||
- This is the foundation for real-time updates in Task 008
|
||||
445
tasks/done/008-sse-integration.md
Normal file
445
tasks/done/008-sse-integration.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# Task 008: SSE Integration - Real-time Message Streaming
|
||||
|
||||
> Note: References to `message-stream.tsx` here are legacy; the current UI uses `message-stream-v2.tsx` with the normalized message store.
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 006 (Instance/Session tabs) complete
|
||||
- Task 007 (Message display) complete
|
||||
- SDK client configured per instance
|
||||
- Understanding of EventSource API
|
||||
|
||||
## Context
|
||||
|
||||
The OpenCode server emits events via SSE at the `/events` endpoint. These events include:
|
||||
|
||||
- Message updates (streaming content)
|
||||
- Session updates (new sessions, title changes)
|
||||
- Tool execution status updates
|
||||
- Server status changes
|
||||
|
||||
We need to:
|
||||
|
||||
1. Create an SSE manager to handle connections
|
||||
2. Connect one EventSource per instance
|
||||
3. Route events to the correct instance/session
|
||||
4. Update reactive state to trigger UI updates
|
||||
5. Implement reconnection logic for dropped connections
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create SSE Manager Module
|
||||
|
||||
Create `src/lib/sse-manager.ts`:
|
||||
|
||||
```typescript
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
eventSource: EventSource
|
||||
reconnectAttempts: number
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
}
|
||||
|
||||
interface MessageUpdateEvent {
|
||||
type: "message_updated"
|
||||
sessionId: string
|
||||
messageId: string
|
||||
parts: any[]
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SessionUpdateEvent {
|
||||
type: "session_updated"
|
||||
session: any
|
||||
}
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private maxReconnectAttempts = 5
|
||||
private baseReconnectDelay = 1000
|
||||
|
||||
connect(instanceId: string, port: number): void {
|
||||
if (this.connections.has(instanceId)) {
|
||||
this.disconnect(instanceId)
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/events`
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
eventSource,
|
||||
reconnectAttempts: 0,
|
||||
status: "connecting",
|
||||
}
|
||||
|
||||
this.connections.set(instanceId, connection)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connection.status = "connected"
|
||||
connection.reconnectAttempts = 0
|
||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleEvent(instanceId, data)
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to parse event:", error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connection.status = "error"
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleReconnect(instanceId, port)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(instanceId: string, event: any): void {
|
||||
switch (event.type) {
|
||||
case "message_updated":
|
||||
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
|
||||
break
|
||||
case "session_updated":
|
||||
this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent)
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReconnect(instanceId: string, port: number): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
|
||||
connection.status = "disconnected"
|
||||
return
|
||||
}
|
||||
|
||||
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
|
||||
connection.reconnectAttempts++
|
||||
|
||||
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(instanceId, port)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
||||
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
|
||||
|
||||
getStatus(instanceId: string): SSEConnection["status"] | null {
|
||||
return this.connections.get(instanceId)?.status ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const sseManager = new SSEManager()
|
||||
```
|
||||
|
||||
### Step 2: Integrate SSE Manager with Instance Store
|
||||
|
||||
Update `src/stores/instances.ts` to use SSE manager:
|
||||
|
||||
```typescript
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
// In createInstance function, after SDK client is created:
|
||||
async function createInstance(folder: string) {
|
||||
// ... existing code to spawn server and create SDK client ...
|
||||
|
||||
// Connect SSE
|
||||
sseManager.connect(instance.id, port)
|
||||
|
||||
// Set up event handlers
|
||||
sseManager.onMessageUpdate = (instanceId, event) => {
|
||||
handleMessageUpdate(instanceId, event)
|
||||
}
|
||||
|
||||
sseManager.onSessionUpdate = (instanceId, event) => {
|
||||
handleSessionUpdate(instanceId, event)
|
||||
}
|
||||
}
|
||||
|
||||
// In removeInstance function:
|
||||
async function removeInstance(id: string) {
|
||||
// Disconnect SSE before removing
|
||||
sseManager.disconnect(id)
|
||||
|
||||
// ... existing cleanup code ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Handle Message Update Events
|
||||
|
||||
Create message update handler in instance store:
|
||||
|
||||
```typescript
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
const session = instance.sessions.get(event.sessionId)
|
||||
if (!session) return
|
||||
|
||||
// Find or create message
|
||||
let message = session.messages.find((m) => m.id === event.messageId)
|
||||
|
||||
if (!message) {
|
||||
// New message - add it
|
||||
message = {
|
||||
id: event.messageId,
|
||||
sessionId: event.sessionId,
|
||||
type: "assistant", // Determine from event
|
||||
parts: event.parts,
|
||||
timestamp: Date.now(),
|
||||
status: event.status,
|
||||
}
|
||||
session.messages.push(message)
|
||||
} else {
|
||||
// Update existing message
|
||||
message.parts = event.parts
|
||||
message.status = event.status
|
||||
}
|
||||
|
||||
// Trigger reactivity - update the map reference
|
||||
instances.set(instanceId, { ...instance })
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle Session Update Events
|
||||
|
||||
Create session update handler:
|
||||
|
||||
```typescript
|
||||
function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
const existingSession = instance.sessions.get(event.session.id)
|
||||
|
||||
if (!existingSession) {
|
||||
// New session - add it
|
||||
const newSession = {
|
||||
id: event.session.id,
|
||||
instanceId,
|
||||
title: event.session.title || "Untitled",
|
||||
parentId: event.session.parentId,
|
||||
agent: event.session.agent,
|
||||
model: event.session.model,
|
||||
messages: [],
|
||||
status: "idle",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
instance.sessions.set(event.session.id, newSession)
|
||||
|
||||
// Auto-create tab for child sessions
|
||||
if (event.session.parentId) {
|
||||
console.log(`[SSE] New child session created: ${event.session.id}`)
|
||||
// Optionally auto-switch to new session
|
||||
// instance.activeSessionId = event.session.id
|
||||
}
|
||||
} else {
|
||||
// Update existing session
|
||||
existingSession.title = event.session.title || existingSession.title
|
||||
existingSession.agent = event.session.agent || existingSession.agent
|
||||
existingSession.model = event.session.model || existingSession.model
|
||||
existingSession.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
instances.set(instanceId, { ...instance })
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add Connection Status Indicator
|
||||
|
||||
Update `src/components/message-stream.tsx` to show connection status:
|
||||
|
||||
```typescript
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
function MessageStream(props) {
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
{/* Connection status indicator */}
|
||||
<div class="flex items-center justify-end px-4 py-2 text-xs text-gray-500">
|
||||
{connectionStatus() === "connected" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === "connecting" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
Connecting...
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === "error" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing message list */}
|
||||
{/* ... */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Test SSE Connection
|
||||
|
||||
Create a test utility to verify SSE is working:
|
||||
|
||||
```typescript
|
||||
// In browser console or test file:
|
||||
async function testSSE() {
|
||||
// Manually trigger a message
|
||||
const response = await fetch("http://localhost:4096/session/SESSION_ID/message", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: "Hello, world!",
|
||||
attachments: [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Check console for SSE events
|
||||
// Should see message_updated events arriving
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Handle Edge Cases
|
||||
|
||||
Add error handling for:
|
||||
|
||||
```typescript
|
||||
// Connection drops during message streaming
|
||||
// - Reconnect logic should handle this automatically
|
||||
// - Messages should resume from last known state
|
||||
|
||||
// Multiple instances with different ports
|
||||
// - Each instance has its own EventSource
|
||||
// - Events routed correctly via instanceId
|
||||
|
||||
// Instance removed while connected
|
||||
// - EventSource closed before instance cleanup
|
||||
// - No memory leaks
|
||||
|
||||
// Page visibility changes (browser tab inactive)
|
||||
// - EventSource may pause, reconnect on focus
|
||||
// - Consider using Page Visibility API to manage connections
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- [ ] Open instance, verify SSE connection established
|
||||
- [ ] Send message, verify streaming events arrive
|
||||
- [ ] Check browser DevTools Network tab for SSE connection
|
||||
- [ ] Verify connection status indicator shows "Connected"
|
||||
- [ ] Kill server process, verify reconnection attempts
|
||||
- [ ] Restart server, verify successful reconnection
|
||||
- [ ] Open multiple instances, verify independent connections
|
||||
- [ ] Switch between instances, verify events route correctly
|
||||
- [ ] Close instance tab, verify EventSource closed cleanly
|
||||
|
||||
### Testing Message Streaming
|
||||
|
||||
- [ ] Send message, watch events in console
|
||||
- [ ] Verify message parts update in real-time
|
||||
- [ ] Check assistant response streams character by character
|
||||
- [ ] Verify tool calls appear as they execute
|
||||
- [ ] Confirm message status updates (streaming → complete)
|
||||
|
||||
### Testing Child Sessions
|
||||
|
||||
- [ ] Trigger action that creates child session
|
||||
- [ ] Verify session_updated event received
|
||||
- [ ] Confirm new session tab appears
|
||||
- [ ] Check parentId correctly set
|
||||
|
||||
### Testing Reconnection
|
||||
|
||||
- [ ] Disconnect network, verify reconnection attempts
|
||||
- [ ] Reconnect network, verify successful reconnection
|
||||
- [ ] Verify exponential backoff delays
|
||||
- [ ] Confirm max attempts limit works
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] SSE connection established when instance created
|
||||
- [ ] Message updates arrive in real-time
|
||||
- [ ] Session updates handled correctly
|
||||
- [ ] Child sessions auto-create tabs
|
||||
- [ ] Connection status visible in UI
|
||||
- [ ] Reconnection logic works with exponential backoff
|
||||
- [ ] Multiple instances have independent connections
|
||||
- [ ] EventSource closed when instance removed
|
||||
- [ ] No console errors during normal operation
|
||||
- [ ] Events route to correct instance/session
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Note: Per MVP principles, don't over-optimize**
|
||||
|
||||
- Simple event handling - no batching needed
|
||||
- Direct state updates trigger reactivity
|
||||
- Reconnection uses exponential backoff
|
||||
- Only optimize if lag occurs in testing
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Event batching for high-frequency updates
|
||||
- Delta updates instead of full message parts
|
||||
- Offline queue for events missed during disconnect
|
||||
- Page Visibility API integration
|
||||
- Event compression for large payloads
|
||||
|
||||
## References
|
||||
|
||||
- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling)
|
||||
- [Architecture - Communication Layer](../docs/architecture.md#communication-layer)
|
||||
- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep reconnection logic simple for MVP
|
||||
- Log all SSE events to console for debugging
|
||||
- Test with long-running streaming responses
|
||||
- Verify memory usage doesn't grow over time
|
||||
- Consider adding SSE event debugging panel (optional)
|
||||
520
tasks/done/009-prompt-input-basic.md
Normal file
520
tasks/done/009-prompt-input-basic.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Task 009: Prompt Input Basic - Text Input with Send Functionality
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 007 (Message display) complete
|
||||
- Task 008 (SSE integration) complete
|
||||
- Active session available
|
||||
- SDK client configured
|
||||
|
||||
## Context
|
||||
|
||||
The prompt input is the primary way users interact with OpenCode. For the MVP, we need:
|
||||
|
||||
- Simple text input (multi-line textarea)
|
||||
- Send button
|
||||
- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line)
|
||||
- Loading state while assistant is responding
|
||||
- Basic validation (empty message prevention)
|
||||
|
||||
Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create Prompt Input Component
|
||||
|
||||
Create `src/components/prompt-input.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show } from "solid-js"
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [sending, setSending] = createSignal(false)
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
if (!text || sending() || props.disabled) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await props.onSend(text)
|
||||
setPrompt("")
|
||||
|
||||
// Auto-resize textarea back to initial size
|
||||
if (textareaRef) {
|
||||
textareaRef.style.height = "auto"
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setSending(false)
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
setPrompt(target.value)
|
||||
|
||||
// Auto-resize textarea
|
||||
target.style.height = "auto"
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||
}
|
||||
|
||||
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
|
||||
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div class="prompt-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder="Type your message or /command..."
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending() || props.disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
class="send-button"
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
|
||||
<span class="spinner-small" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<span class="hint">
|
||||
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Send Message Function to Sessions Store
|
||||
|
||||
Update `src/stores/sessions.ts` to add message sending:
|
||||
|
||||
```typescript
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: string[] = [],
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
sessionId,
|
||||
type: "user",
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
status: "sending",
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const newMessages = [...updatedSession.messages, userMessage]
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages: newMessages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
// Send to server using session.prompt (not session.message)
|
||||
await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
messageID: userMessage.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Update user message status
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const messages = updatedSession.messages.map((m) =>
|
||||
m.id === userMessage.id ? { ...m, status: "sent" as const } : m,
|
||||
)
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
// Update user message with error
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const messages = updatedSession.messages.map((m) =>
|
||||
m.id === userMessage.id ? { ...m, status: "error" as const } : m,
|
||||
)
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Export it
|
||||
export { sendMessage }
|
||||
```
|
||||
|
||||
### Step 3: Integrate Prompt Input into App
|
||||
|
||||
Update `src/App.tsx` to add the prompt input:
|
||||
|
||||
```typescript
|
||||
import PromptInput from "./components/prompt-input"
|
||||
import { sendMessage } from "./stores/sessions"
|
||||
|
||||
// In the SessionMessages component or create a new wrapper component
|
||||
const SessionView: Component<{
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
}> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSendMessage(prompt: string) {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={session()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">Session not found</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(s) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
/>
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Replace SessionMessages usage with SessionView
|
||||
```
|
||||
|
||||
### Step 4: Add Styling
|
||||
|
||||
Add to `src/index.css`:
|
||||
|
||||
```css
|
||||
.prompt-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
background-color: var(--background);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
padding: 0 16px 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.session-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Message Display for User Messages
|
||||
|
||||
Make sure user messages display correctly in `src/components/message-item.tsx`:
|
||||
|
||||
```typescript
|
||||
// User messages should show with user styling
|
||||
// Message status should be visible (sending, sent, error)
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">Failed to send message</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
```
|
||||
|
||||
### Step 6: Handle Real-time Response
|
||||
|
||||
The SSE integration from Task 008 should automatically:
|
||||
|
||||
1. Receive message_updated events
|
||||
2. Create assistant message in the session
|
||||
3. Stream message parts as they arrive
|
||||
4. Update the UI in real-time
|
||||
|
||||
No additional code needed - this should "just work" if SSE is connected.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Basic Functionality
|
||||
|
||||
- [ ] Prompt input renders at bottom of session view
|
||||
- [ ] Can type text in the textarea
|
||||
- [ ] Textarea auto-expands as you type (up to max height)
|
||||
- [ ] Send button is disabled when input is empty
|
||||
- [ ] Send button is enabled when text is present
|
||||
|
||||
### Sending Messages
|
||||
|
||||
- [ ] Click send button - message appears in stream
|
||||
- [ ] Press Enter - message sends
|
||||
- [ ] Press Shift+Enter - adds new line (doesn't send)
|
||||
- [ ] Input clears after sending
|
||||
- [ ] Focus returns to input after sending
|
||||
|
||||
### User Message Display
|
||||
|
||||
- [ ] User message appears immediately (optimistic update)
|
||||
- [ ] User message shows "Sending..." state briefly
|
||||
- [ ] User message updates to "sent" after API confirms
|
||||
- [ ] Error state shows if send fails
|
||||
|
||||
### Assistant Response
|
||||
|
||||
- [ ] After sending, SSE receives message updates
|
||||
- [ ] Assistant message appears in stream
|
||||
- [ ] Message parts stream in real-time
|
||||
- [ ] Tool calls appear as they execute
|
||||
- [ ] Connection status indicator shows "Connected"
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Can't send while previous message is processing
|
||||
- [ ] Empty/whitespace-only messages don't send
|
||||
- [ ] Very long messages work correctly
|
||||
- [ ] Multiple rapid sends are queued properly
|
||||
- [ ] Network error shows helpful message
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can type and send text messages
|
||||
- [ ] Enter key sends message
|
||||
- [ ] Shift+Enter creates new line
|
||||
- [ ] Send button works correctly
|
||||
- [ ] User messages appear immediately
|
||||
- [ ] Assistant responses stream in real-time via SSE
|
||||
- [ ] Input auto-expands up to max height
|
||||
- [ ] Loading states are clear
|
||||
- [ ] Error handling works
|
||||
- [ ] No console errors during normal operation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Per MVP principles - keep it simple:**
|
||||
|
||||
- Direct API calls - no batching
|
||||
- Optimistic updates for user messages
|
||||
- SSE handles streaming automatically
|
||||
- No debouncing or throttling needed
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Slash command autocomplete (Task 021)
|
||||
- File attachment support (Task 022)
|
||||
- Drag & drop files (Task 023)
|
||||
- Attachment chips (Task 024)
|
||||
- Message history navigation (Task 025)
|
||||
- Multi-line paste handling
|
||||
- Rich text formatting
|
||||
- Message drafts persistence
|
||||
|
||||
## References
|
||||
|
||||
- [User Interface - Prompt Input](../docs/user-interface.md#5-prompt-input)
|
||||
- [Technical Implementation - Message Rendering](../docs/technical-implementation.md#message-rendering)
|
||||
- [Task 008 - SSE Integration](./008-sse-integration.md)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Focus on core functionality - no fancy features yet
|
||||
- Test thoroughly with SSE to ensure real-time streaming works
|
||||
- This completes the basic chat loop - users can now interact with OpenCode
|
||||
- Keep error messages user-friendly and actionable
|
||||
- Ensure keyboard shortcuts work as expected
|
||||
603
tasks/done/010-tool-call-rendering.md
Normal file
603
tasks/done/010-tool-call-rendering.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Task 010: Tool Call Rendering - Display Tool Executions Inline
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement interactive tool call rendering that displays tool executions inline within assistant messages. Users should be able to expand/collapse tool calls to see input, output, and execution status.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 007 (Message display) complete
|
||||
- Task 008 (SSE integration) complete
|
||||
- Task 009 (Prompt input) complete
|
||||
- Messages streaming from API
|
||||
- Tool call data available in message parts
|
||||
|
||||
## Context
|
||||
|
||||
When OpenCode executes tools (bash commands, file edits, etc.), these should be visible to the user in the message stream. Tool calls need:
|
||||
|
||||
- Collapsed state showing summary (tool name + brief description)
|
||||
- Expanded state showing full input/output
|
||||
- Status indicators (pending, running, success, error)
|
||||
- Click to toggle expand/collapse
|
||||
- Syntax highlighting for code in input/output
|
||||
|
||||
This provides transparency into what OpenCode is doing and helps users understand the assistant's actions.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Define Tool Call Types
|
||||
|
||||
Create or update `src/types/message.ts`:
|
||||
|
||||
```typescript
|
||||
export interface ToolCallPart {
|
||||
type: "tool_call"
|
||||
id: string
|
||||
tool: string
|
||||
input: any
|
||||
output?: any
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_call"
|
||||
text?: string
|
||||
id?: string
|
||||
tool?: string
|
||||
input?: any
|
||||
output?: any
|
||||
status?: "pending" | "running" | "success" | "error"
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Tool Call Component
|
||||
|
||||
Create `src/components/tool-call.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show, Switch, Match } from "solid-js"
|
||||
import type { ToolCallPart } from "../types/message"
|
||||
|
||||
interface ToolCallProps {
|
||||
part: ToolCallPart
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded())
|
||||
}
|
||||
|
||||
function getToolIcon(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "⚡"
|
||||
case "edit":
|
||||
return "✏️"
|
||||
case "read":
|
||||
return "📖"
|
||||
case "write":
|
||||
return "📝"
|
||||
case "glob":
|
||||
return "🔍"
|
||||
case "grep":
|
||||
return "🔎"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏳"
|
||||
case "running":
|
||||
return "⟳"
|
||||
case "success":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function getToolSummary(part: ToolCallPart): string {
|
||||
const { tool, input } = part
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return input?.command || "Execute command"
|
||||
case "edit":
|
||||
return `Edit ${input?.filePath || "file"}`
|
||||
case "read":
|
||||
return `Read ${input?.filePath || "file"}`
|
||||
case "write":
|
||||
return `Write ${input?.filePath || "file"}`
|
||||
case "glob":
|
||||
return `Find ${input?.pattern || "files"}`
|
||||
case "grep":
|
||||
return `Search for "${input?.pattern || "pattern"}"`
|
||||
default:
|
||||
return tool
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(obj: any): string {
|
||||
if (typeof obj === "string") return obj
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="tool-call"
|
||||
classList={{
|
||||
"tool-call-expanded": expanded(),
|
||||
"tool-call-error": props.part.status === "error",
|
||||
"tool-call-success": props.part.status === "success",
|
||||
"tool-call-running": props.part.status === "running",
|
||||
}}
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<div class="tool-call-header">
|
||||
<span class="tool-call-expand-icon">{expanded() ? "▼" : "▶"}</span>
|
||||
<span class="tool-call-icon">{getToolIcon(props.part.tool)}</span>
|
||||
<span class="tool-call-tool">{props.part.tool}:</span>
|
||||
<span class="tool-call-summary">{getToolSummary(props.part)}</span>
|
||||
<span class="tool-call-status">{getStatusIcon(props.part.status)}</span>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="tool-call-body" onClick={(e) => e.stopPropagation()}>
|
||||
<Show when={props.part.input}>
|
||||
<div class="tool-call-section">
|
||||
<div class="tool-call-section-title">Input:</div>
|
||||
<pre class="tool-call-content">
|
||||
<code>{formatJson(props.part.input)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.output !== undefined}>
|
||||
<div class="tool-call-section">
|
||||
<div class="tool-call-section-title">Output:</div>
|
||||
<pre class="tool-call-content">
|
||||
<code>{formatJson(props.part.output)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.error}>
|
||||
<div class="tool-call-section tool-call-error-section">
|
||||
<div class="tool-call-section-title">Error:</div>
|
||||
<pre class="tool-call-content tool-call-error-content">
|
||||
<code>{props.part.error}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.status === "running"}>
|
||||
<div class="tool-call-running-indicator">
|
||||
<span class="spinner-small" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Message Item to Render Tool Calls
|
||||
|
||||
Update `src/components/message-item.tsx`:
|
||||
|
||||
```typescript
|
||||
import { For, Show, Switch, Match } from "solid-js"
|
||||
import type { Message, MessagePart } from "../types/message"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-item"
|
||||
classList={{
|
||||
"message-user": isUser(),
|
||||
"message-assistant": !isUser(),
|
||||
}}
|
||||
>
|
||||
<div class="message-header">
|
||||
<span class="message-author">{isUser() ? "You" : "Assistant"}</span>
|
||||
<span class="message-timestamp">
|
||||
{new Date(props.message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
<For each={props.message.parts}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={part.type === "text"}>
|
||||
<div class="message-text">{part.text}</div>
|
||||
</Match>
|
||||
<Match when={part.type === "tool_call"}>
|
||||
<ToolCall part={part as any} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">Failed to send message</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Tool Call Styling
|
||||
|
||||
Add to `src/index.css`:
|
||||
|
||||
```css
|
||||
/* Tool Call Styles */
|
||||
.tool-call {
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--secondary-bg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.tool-call:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tool-call-expanded {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tool-call-success {
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.tool-call-error {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.tool-call-running {
|
||||
border-left: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.tool-call-expanded .tool-call-expand-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-call-tool {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
font-size: 14px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-call-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.tool-call-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tool-call-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-call-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-error-section {
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tool-call-running-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tool-call {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tool-call-body {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update SSE Handler to Parse Tool Calls
|
||||
|
||||
Update `src/lib/sse-manager.ts` to correctly parse tool call parts from SSE events:
|
||||
|
||||
```typescript
|
||||
function handleMessageUpdate(event: MessageUpdateEvent, instanceId: string) {
|
||||
// When a message part arrives via SSE, check if it's a tool call
|
||||
const part = event.part
|
||||
|
||||
if (part.type === "tool_call") {
|
||||
// Parse tool call data
|
||||
const toolCallPart: ToolCallPart = {
|
||||
type: "tool_call",
|
||||
id: part.id || `tool-${Date.now()}`,
|
||||
tool: part.tool || "unknown",
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
status: part.status || "pending",
|
||||
error: part.error,
|
||||
}
|
||||
|
||||
// Add or update in messages
|
||||
updateMessagePart(instanceId, event.sessionId, event.messageId, toolCallPart)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Handle Tool Call Updates
|
||||
|
||||
Ensure that tool calls can update their status as they execute:
|
||||
|
||||
```typescript
|
||||
// In sessions store
|
||||
function updateMessagePart(instanceId: string, sessionId: string, messageId: string, part: MessagePart) {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const session = instanceSessions.get(sessionId)
|
||||
|
||||
if (session) {
|
||||
const messages = session.messages.map((msg) => {
|
||||
if (msg.id === messageId) {
|
||||
// Find existing part by ID and update, or append
|
||||
const partIndex = msg.parts.findIndex((p) => p.type === "tool_call" && p.id === part.id)
|
||||
|
||||
if (partIndex !== -1) {
|
||||
const updatedParts = [...msg.parts]
|
||||
updatedParts[partIndex] = part
|
||||
return { ...msg, parts: updatedParts }
|
||||
} else {
|
||||
return { ...msg, parts: [...msg.parts, part] }
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
|
||||
instanceSessions.set(sessionId, { ...session, messages })
|
||||
}
|
||||
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Rendering
|
||||
|
||||
- [ ] Tool calls render in collapsed state by default
|
||||
- [ ] Tool icon displays correctly for each tool type
|
||||
- [ ] Tool summary shows meaningful description
|
||||
- [ ] Status icon displays correctly (pending, running, success, error)
|
||||
- [ ] Styling is consistent with design
|
||||
|
||||
### Expand/Collapse
|
||||
|
||||
- [ ] Click tool call header - expands to show details
|
||||
- [ ] Click again - collapses back to summary
|
||||
- [ ] Expand icon rotates correctly
|
||||
- [ ] Clicking inside expanded body doesn't collapse
|
||||
- [ ] Multiple tool calls can be expanded independently
|
||||
|
||||
### Content Display
|
||||
|
||||
- [ ] Input section shows tool input data
|
||||
- [ ] Output section shows tool output data
|
||||
- [ ] JSON is formatted with proper indentation
|
||||
- [ ] Code/text is displayed in monospace font
|
||||
- [ ] Long output is scrollable horizontally
|
||||
|
||||
### Status Indicators
|
||||
|
||||
- [ ] Pending status shows waiting icon (⏳)
|
||||
- [ ] Running status shows spinner and "Executing..."
|
||||
- [ ] Success status shows checkmark (✓)
|
||||
- [ ] Error status shows X (✗) and error message
|
||||
- [ ] Border color changes based on status
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
- [ ] Tool calls appear as SSE events arrive
|
||||
- [ ] Status updates from pending → running → success
|
||||
- [ ] Output appears when tool completes
|
||||
- [ ] Error state shows if tool fails
|
||||
- [ ] UI updates smoothly without flashing
|
||||
|
||||
### Different Tool Types
|
||||
|
||||
- [ ] Bash commands display correctly
|
||||
- [ ] File edits show file path and changes
|
||||
- [ ] File reads show file path
|
||||
- [ ] Glob/grep show patterns
|
||||
- [ ] Unknown tools have fallback icon
|
||||
|
||||
### Error Handling
|
||||
|
||||
- [ ] Tool errors display error message
|
||||
- [ ] Error section has red styling
|
||||
- [ ] Error state is clearly visible
|
||||
- [ ] Can expand to see full error details
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Tool calls render inline in assistant messages
|
||||
- [ ] Default collapsed state shows summary
|
||||
- [ ] Click to expand shows full input/output
|
||||
- [ ] Status indicators work correctly
|
||||
- [ ] Real-time updates via SSE work
|
||||
- [ ] Multiple tool calls in one message work
|
||||
- [ ] Error states are clear and helpful
|
||||
- [ ] Styling matches design specifications
|
||||
- [ ] No performance issues with many tool calls
|
||||
- [ ] No console errors during normal operation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Per MVP principles - keep it simple:**
|
||||
|
||||
- Render all tool calls - no virtualization
|
||||
- No lazy loading of tool content
|
||||
- Simple JSON.stringify for formatting
|
||||
- Direct DOM updates via SolidJS reactivity
|
||||
- Add optimizations only if problems arise
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Syntax highlighting for code in input/output (using Shiki)
|
||||
- Diff view for file edits
|
||||
- Copy button for tool output
|
||||
- Link to file in file operations
|
||||
- Collapsible sections within tool calls
|
||||
- Tool execution time display
|
||||
- Retry failed tools
|
||||
- Export tool output
|
||||
|
||||
## References
|
||||
|
||||
- [User Interface - Tool Call Rendering](../docs/user-interface.md#3-messages-area)
|
||||
- [Technical Implementation - Tool Call Rendering](../docs/technical-implementation.md#message-rendering)
|
||||
- [Build Roadmap - Phase 2](../docs/build-roadmap.md#phase-2-core-chat-interface-week-2)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Focus on clear visual hierarchy - collapsed view should be scannable
|
||||
- Status indicators help users understand what's happening
|
||||
- Errors should be prominent but not alarming
|
||||
- Tool calls are a key differentiator - make them shine
|
||||
- Test with real OpenCode responses to ensure data format matches
|
||||
- Consider adding debug logging to verify SSE data structure
|
||||
527
tasks/done/011-agent-model-selectors.md
Normal file
527
tasks/done/011-agent-model-selectors.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# Task 011: Agent and Model Selectors
|
||||
|
||||
## Goal
|
||||
|
||||
Implement dropdown selectors for switching agents and models in the active session. These controls appear in the control bar above the prompt input and allow users to change the agent or model for the current conversation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 010 (Tool Call Rendering) completed
|
||||
- Session state management implemented
|
||||
- SDK client integration functional
|
||||
- UI components library (Kobalte) configured
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Agent selector dropdown displays current agent
|
||||
- [x] Agent dropdown lists all available agents
|
||||
- [x] Selecting agent updates session configuration
|
||||
- [x] Model selector dropdown displays current model
|
||||
- [x] Model dropdown lists all available models (flat list with provider name shown)
|
||||
- [x] Selecting model updates session configuration
|
||||
- [x] Changes persist across app restarts (stored in session state)
|
||||
- [x] Loading states during fetch/update (automatic via createEffect)
|
||||
- [x] Error handling for failed updates (logged to console)
|
||||
- [x] Keyboard navigation works (provided by Kobalte Select)
|
||||
- [x] Visual feedback on selection change
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**Completed:** All acceptance criteria met with the following implementation details:
|
||||
|
||||
1. **Agent Selector** (`src/components/agent-selector.tsx`):
|
||||
- Uses Kobalte Select component for accessibility
|
||||
- Fetches agents via `fetchAgents()` on mount
|
||||
- Displays agent name and description
|
||||
- Light mode styling matching the rest of the app
|
||||
- Compact size (text-xs, smaller padding) for bottom placement
|
||||
- Updates session state locally (agent/model are sent with each prompt, not via separate update API)
|
||||
|
||||
2. **Model Selector** (`src/components/model-selector.tsx`):
|
||||
- Uses Kobalte Select component for accessibility
|
||||
- Fetches providers and models via `fetchProviders()` on mount
|
||||
- Flattens model list from all providers for easier selection
|
||||
- Shows provider name alongside model name
|
||||
- **Search functionality** - inline search input at top of dropdown
|
||||
- Filters models by name, provider name, or model ID
|
||||
- Shows "No models found" message when no matches
|
||||
- Clears search query when model is selected
|
||||
- Light mode styling matching the rest of the app
|
||||
- Compact size (text-xs, smaller padding) for bottom placement
|
||||
- Updates session state locally
|
||||
|
||||
3. **Integration** (`src/components/prompt-input.tsx`):
|
||||
- Integrated selectors directly into prompt input hints area
|
||||
- Positioned bottom right, on same line as "Enter to send" hint
|
||||
- Removed separate controls-bar component for cleaner integration
|
||||
- Passes agent/model props and change handlers from parent
|
||||
|
||||
4. **Session Store Updates** (`src/stores/sessions.ts`):
|
||||
- Added `updateSessionAgent()` - updates session agent locally
|
||||
- Added `updateSessionModel()` - updates session model locally
|
||||
- Note: The SDK doesn't support updating agent/model via separate API calls
|
||||
- Agent and model are sent with each prompt via the `sendMessage()` function
|
||||
|
||||
5. **Integration** (`src/App.tsx`):
|
||||
- Passes agent, model, and change handlers to PromptInput
|
||||
- SessionView component updated with new props
|
||||
|
||||
**Design Decisions:**
|
||||
|
||||
- Simplified model selector to use flat list instead of grouped (Kobalte 0.13.11 Select doesn't support groups)
|
||||
- Agent and model changes are stored locally and sent with each prompt request
|
||||
- No separate API call to update session configuration (matches SDK limitations)
|
||||
- Used SolidJS's `createEffect` for automatic data fetching on component mount
|
||||
- Integrated controls into prompt input area rather than separate bar for better space usage
|
||||
- Positioned bottom right on hints line for easy access without obscuring content
|
||||
- Light mode only styling (removed dark mode classes) to match existing app design
|
||||
- Compact sizing (text-xs, reduced padding) to fit naturally in the hints area
|
||||
- Search input with icon in sticky header at top of model dropdown
|
||||
- Real-time filtering across model name, provider name, and model ID
|
||||
- Search preserves dropdown open state and clears on selection
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Define Types
|
||||
|
||||
Create `src/types/config.ts`:
|
||||
|
||||
```typescript
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Model {
|
||||
providerId: string
|
||||
modelId: string
|
||||
name: string
|
||||
contextWindow?: number
|
||||
capabilities?: string[]
|
||||
}
|
||||
|
||||
interface ModelProvider {
|
||||
id: string
|
||||
name: string
|
||||
models: Model[]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Fetch Available Options
|
||||
|
||||
Extend SDK hooks in `src/hooks/use-session.ts`:
|
||||
|
||||
```typescript
|
||||
function useAgents(instanceId: string) {
|
||||
const [agents, setAgents] = createSignal<Agent[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [error, setError] = createSignal<Error | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
const client = getClient(instanceId)
|
||||
if (!client) return
|
||||
|
||||
setLoading(true)
|
||||
client.config
|
||||
.agents()
|
||||
.then(setAgents)
|
||||
.catch(setError)
|
||||
.finally(() => setLoading(false))
|
||||
})
|
||||
|
||||
return { agents, loading, error }
|
||||
}
|
||||
|
||||
function useModels(instanceId: string) {
|
||||
const [providers, setProviders] = createSignal<ModelProvider[]>([])
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [error, setError] = createSignal<Error | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
const client = getClient(instanceId)
|
||||
if (!client) return
|
||||
|
||||
setLoading(true)
|
||||
client.config
|
||||
.models()
|
||||
.then((data) => {
|
||||
// Group models by provider
|
||||
const grouped = groupModelsByProvider(data)
|
||||
setProviders(grouped)
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setLoading(false))
|
||||
})
|
||||
|
||||
return { providers, loading, error }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Agent Selector Component
|
||||
|
||||
Create `src/components/agent-selector.tsx`:
|
||||
|
||||
```typescript
|
||||
import { Select } from '@kobalte/core'
|
||||
import { createMemo } from 'solid-js'
|
||||
import { useAgents } from '../hooks/use-session'
|
||||
|
||||
interface AgentSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentAgent: string
|
||||
onAgentChange: (agent: string) => void
|
||||
}
|
||||
|
||||
export function AgentSelector(props: AgentSelectorProps) {
|
||||
const { agents, loading, error } = useAgents(props.instanceId)
|
||||
|
||||
const currentAgentInfo = createMemo(() =>
|
||||
agents().find(a => a.id === props.currentAgent)
|
||||
)
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={props.currentAgent}
|
||||
onChange={props.onAgentChange}
|
||||
options={agents()}
|
||||
optionValue="id"
|
||||
optionTextValue="name"
|
||||
placeholder="Select agent..."
|
||||
itemComponent={props => (
|
||||
<Select.Item item={props.item} class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer">
|
||||
<Select.ItemLabel class="font-medium">{props.item.rawValue.name}</Select.ItemLabel>
|
||||
<Select.ItemDescription class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{props.item.rawValue.description}
|
||||
</Select.ItemDescription>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<Select.Value<Agent>>
|
||||
{state => (
|
||||
<span class="text-sm">
|
||||
Agent: {state.selectedOption()?.name ?? 'Select...'}
|
||||
</span>
|
||||
)}
|
||||
</Select.Value>
|
||||
<Select.Icon class="ml-2">
|
||||
<ChevronDownIcon class="w-4 h-4" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
|
||||
<Select.Listbox />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Model Selector Component
|
||||
|
||||
Create `src/components/model-selector.tsx`:
|
||||
|
||||
```typescript
|
||||
import { Select } from '@kobalte/core'
|
||||
import { For, createMemo } from 'solid-js'
|
||||
import { useModels } from '../hooks/use-session'
|
||||
|
||||
interface ModelSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => void
|
||||
}
|
||||
|
||||
export function ModelSelector(props: ModelSelectorProps) {
|
||||
const { providers, loading, error } = useModels(props.instanceId)
|
||||
|
||||
const allModels = createMemo(() =>
|
||||
providers().flatMap(p => p.models.map(m => ({ ...m, provider: p.name })))
|
||||
)
|
||||
|
||||
const currentModelInfo = createMemo(() =>
|
||||
allModels().find(
|
||||
m => m.providerId === props.currentModel.providerId &&
|
||||
m.modelId === props.currentModel.modelId
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={`${props.currentModel.providerId}/${props.currentModel.modelId}`}
|
||||
onChange={value => {
|
||||
const [providerId, modelId] = value.split('/')
|
||||
props.onModelChange({ providerId, modelId })
|
||||
}}
|
||||
options={allModels()}
|
||||
optionValue={m => `${m.providerId}/${m.modelId}`}
|
||||
optionTextValue="name"
|
||||
placeholder="Select model..."
|
||||
itemComponent={props => (
|
||||
<Select.Item
|
||||
item={props.item}
|
||||
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
>
|
||||
<Select.ItemLabel class="font-medium">
|
||||
{props.item.rawValue.name}
|
||||
</Select.ItemLabel>
|
||||
<Select.ItemDescription class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{props.item.rawValue.provider}
|
||||
{props.item.rawValue.contextWindow &&
|
||||
` • ${(props.item.rawValue.contextWindow / 1000).toFixed(0)}k context`
|
||||
}
|
||||
</Select.ItemDescription>
|
||||
</Select.Item>
|
||||
)}
|
||||
>
|
||||
<Select.Trigger class="inline-flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<Select.Value<Model>>
|
||||
{state => (
|
||||
<span class="text-sm">
|
||||
Model: {state.selectedOption()?.name ?? 'Select...'}
|
||||
</span>
|
||||
)}
|
||||
</Select.Value>
|
||||
<Select.Icon class="ml-2">
|
||||
<ChevronDownIcon class="w-4 h-4" />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-80 overflow-auto">
|
||||
<For each={providers()}>
|
||||
{provider => (
|
||||
<>
|
||||
<Select.Group>
|
||||
<Select.GroupLabel class="px-3 py-1 text-xs font-semibold text-gray-500 dark:text-gray-500 uppercase">
|
||||
{provider.name}
|
||||
</Select.GroupLabel>
|
||||
<For each={provider.models}>
|
||||
{model => (
|
||||
<Select.Item
|
||||
value={`${model.providerId}/${model.modelId}`}
|
||||
class="px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
>
|
||||
<Select.ItemLabel>{model.name}</Select.ItemLabel>
|
||||
</Select.Item>
|
||||
)}
|
||||
</For>
|
||||
</Select.Group>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Create Controls Bar Component
|
||||
|
||||
Create `src/components/controls-bar.tsx`:
|
||||
|
||||
```typescript
|
||||
import { AgentSelector } from './agent-selector'
|
||||
import { ModelSelector } from './model-selector'
|
||||
|
||||
interface ControlsBarProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
currentAgent: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
onAgentChange: (agent: string) => Promise<void>
|
||||
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export function ControlsBar(props: ControlsBarProps) {
|
||||
const handleAgentChange = async (agent: string) => {
|
||||
try {
|
||||
await props.onAgentChange(agent)
|
||||
} catch (error) {
|
||||
console.error('Failed to change agent:', error)
|
||||
// Show error toast
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = async (model: { providerId: string; modelId: string }) => {
|
||||
try {
|
||||
await props.onModelChange(model)
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error)
|
||||
// Show error toast
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-4 px-4 py-2 border-t border-gray-200 dark:border-gray-800">
|
||||
<AgentSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
currentAgent={props.currentAgent}
|
||||
onAgentChange={handleAgentChange}
|
||||
/>
|
||||
<ModelSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
currentModel={props.currentModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Add Update Methods to Session Hook
|
||||
|
||||
Extend `src/hooks/use-session.ts`:
|
||||
|
||||
```typescript
|
||||
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string) {
|
||||
const client = getClient(instanceId)
|
||||
if (!client) throw new Error("Client not found")
|
||||
|
||||
await client.session.update(sessionId, { agent })
|
||||
|
||||
// Update local state
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (session) {
|
||||
session.agent = agent
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionModel(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
) {
|
||||
const client = getClient(instanceId)
|
||||
if (!client) throw new Error("Client not found")
|
||||
|
||||
await client.session.update(sessionId, { model })
|
||||
|
||||
// Update local state
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (session) {
|
||||
session.model = model
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Integrate into Main Layout
|
||||
|
||||
Update the session view component to include controls bar:
|
||||
|
||||
```typescript
|
||||
function SessionView(props: { instanceId: string; sessionId: string }) {
|
||||
const session = () => getSession(props.instanceId, props.sessionId)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
{/* Messages area */}
|
||||
<div class="flex-1 overflow-auto">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls bar */}
|
||||
<ControlsBar
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
currentAgent={session()?.agent}
|
||||
currentModel={session()?.model}
|
||||
onAgentChange={agent => updateSessionAgent(props.instanceId, props.sessionId, agent)}
|
||||
onModelChange={model => updateSessionModel(props.instanceId, props.sessionId, model)}
|
||||
/>
|
||||
|
||||
{/* Prompt input */}
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Add Loading and Error States
|
||||
|
||||
Enhance selectors with loading states:
|
||||
|
||||
```typescript
|
||||
// In AgentSelector
|
||||
<Show when={loading()}>
|
||||
<div class="px-4 py-2 text-sm text-gray-500">Loading agents...</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="px-4 py-2 text-sm text-red-500">
|
||||
Failed to load agents: {error()?.message}
|
||||
</div>
|
||||
</Show>
|
||||
```
|
||||
|
||||
### 9. Style Dropdowns
|
||||
|
||||
Add Tailwind classes for:
|
||||
|
||||
- Dropdown trigger button
|
||||
- Dropdown content panel
|
||||
- Option items
|
||||
- Hover states
|
||||
- Selected state
|
||||
- Keyboard focus states
|
||||
- Dark mode variants
|
||||
|
||||
### 10. Add Keyboard Navigation
|
||||
|
||||
Ensure Kobalte Select handles:
|
||||
|
||||
- Arrow up/down: Navigate options
|
||||
- Enter: Select option
|
||||
- Escape: Close dropdown
|
||||
- Tab: Move to next control
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. Launch app with an active session
|
||||
2. Verify current agent displays in selector
|
||||
3. Click agent selector
|
||||
4. Verify dropdown opens with agent list
|
||||
5. Select different agent
|
||||
6. Verify session updates (check network request)
|
||||
7. Verify selector shows new agent
|
||||
8. Repeat for model selector
|
||||
9. Test keyboard navigation
|
||||
10. Test with long agent/model names
|
||||
11. Test error state (disconnect network)
|
||||
12. Test loading state (slow network)
|
||||
13. Verify changes persist on session switch
|
||||
14. Verify changes persist on app restart
|
||||
|
||||
## Dependencies for Next Tasks
|
||||
|
||||
- Task 012 (Markdown Rendering) can proceed independently
|
||||
- Task 013 (Logs Tab) can proceed independently
|
||||
- This completes session configuration UI
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Use Kobalte Select component for accessibility
|
||||
- Group models by provider for better UX
|
||||
- Show relevant model metadata (context window, capabilities)
|
||||
- Consider caching agents/models list per instance
|
||||
- Handle case where current agent/model is no longer available
|
||||
- Future: Add search/filter for large model lists
|
||||
- Future: Show model pricing information
|
||||
417
tasks/done/012-markdown-rendering.md
Normal file
417
tasks/done/012-markdown-rendering.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Task 012: Markdown Rendering
|
||||
|
||||
**Status:** Todo
|
||||
**Estimated Time:** 3-4 hours
|
||||
**Phase:** 3 - Essential Features
|
||||
**Dependencies:** 007 (Message Display)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement proper markdown rendering for assistant messages with syntax-highlighted code blocks. Replace basic text display with rich markdown formatting using Marked and Shiki.
|
||||
|
||||
## Context
|
||||
|
||||
Currently messages display as plain text. We need to parse and render markdown content from assistant messages, including:
|
||||
|
||||
- Headings, bold, italic, links
|
||||
- Code blocks with syntax highlighting
|
||||
- Inline code
|
||||
- Lists (ordered and unordered)
|
||||
- Blockquotes
|
||||
- Tables (if needed)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **Markdown Parser Integration**
|
||||
- Use `marked` library for markdown parsing
|
||||
- Configure for safe HTML rendering
|
||||
- Support GitHub-flavored markdown
|
||||
|
||||
2. **Syntax Highlighting**
|
||||
- Use `shiki` for code block highlighting
|
||||
- Support light and dark themes
|
||||
- Support common languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, etc.
|
||||
|
||||
3. **Code Block Features**
|
||||
- Language label displayed
|
||||
- Copy button on hover
|
||||
- Line numbers (optional for MVP)
|
||||
|
||||
4. **Inline Code**
|
||||
- Distinct background color
|
||||
- Monospace font
|
||||
- Subtle padding
|
||||
|
||||
5. **Links**
|
||||
- Open in external browser
|
||||
- Show external link icon
|
||||
- Prevent opening in same window
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
1. **Dependencies**
|
||||
- Install `marked` and `@types/marked`
|
||||
- Install `shiki`
|
||||
- Install `marked-highlight` for integration
|
||||
|
||||
2. **Theme Support**
|
||||
- Light mode: `github-light` theme
|
||||
- Dark mode: `github-dark` theme
|
||||
- Respect system theme preference
|
||||
|
||||
3. **Security**
|
||||
- Sanitize HTML output
|
||||
- No script execution
|
||||
- Safe link handling
|
||||
|
||||
4. **Performance**
|
||||
- Lazy load Shiki highlighter
|
||||
- Cache highlighter instance
|
||||
- Don't re-parse unchanged messages
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
cd packages/opencode-client
|
||||
npm install marked shiki
|
||||
npm install -D @types/marked
|
||||
```
|
||||
|
||||
### Step 2: Create Markdown Utility
|
||||
|
||||
Create `src/lib/markdown.ts`:
|
||||
|
||||
```typescript
|
||||
import { marked } from "marked"
|
||||
import { getHighlighter, type Highlighter } from "shiki"
|
||||
|
||||
let highlighter: Highlighter | null = null
|
||||
|
||||
async function getOrCreateHighlighter() {
|
||||
if (!highlighter) {
|
||||
highlighter = await getHighlighter({
|
||||
themes: ["github-light", "github-dark"],
|
||||
langs: ["typescript", "javascript", "python", "bash", "json", "html", "css", "markdown", "yaml", "sql"],
|
||||
})
|
||||
}
|
||||
return highlighter
|
||||
}
|
||||
|
||||
export async function initMarkdown(isDark: boolean) {
|
||||
const hl = await getOrCreateHighlighter()
|
||||
|
||||
marked.use({
|
||||
async: false,
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = (code: string, language: string | undefined) => {
|
||||
if (!language) {
|
||||
return `<pre><code>${escapeHtml(code)}</code></pre>`
|
||||
}
|
||||
|
||||
try {
|
||||
const html = hl.codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: isDark ? "github-dark" : "github-light",
|
||||
})
|
||||
return html
|
||||
} catch (e) {
|
||||
return `<pre><code class="language-${language}">${escapeHtml(code)}</code></pre>`
|
||||
}
|
||||
}
|
||||
|
||||
renderer.link = (href: string, title: string | null, text: string) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
|
||||
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
marked.use({ renderer })
|
||||
}
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return marked.parse(content) as string
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Markdown Component
|
||||
|
||||
Create `src/components/markdown.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||
import { initMarkdown, renderMarkdown } from '../lib/markdown'
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const [html, setHtml] = createSignal('')
|
||||
const [ready, setReady] = createSignal(false)
|
||||
|
||||
onMount(async () => {
|
||||
await initMarkdown(props.isDark ?? false)
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (ready()) {
|
||||
const rendered = renderMarkdown(props.content)
|
||||
setHtml(rendered)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
if (props.isDark !== undefined) {
|
||||
await initMarkdown(props.isDark)
|
||||
const rendered = renderMarkdown(props.content)
|
||||
setHtml(rendered)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none"
|
||||
innerHTML={html()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Copy Button to Code Blocks
|
||||
|
||||
Create `src/components/code-block.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function CodeBlockWrapper(props: CodeBlockProps) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative group">
|
||||
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={copyCode}
|
||||
class="px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.language}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-2">
|
||||
{props.language}
|
||||
</div>
|
||||
</Show>
|
||||
<div innerHTML={props.code} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Message Component
|
||||
|
||||
Update `src/components/message-item.tsx` to use Markdown component:
|
||||
|
||||
```typescript
|
||||
import { Markdown } from './markdown'
|
||||
|
||||
// In the assistant message rendering:
|
||||
<For each={textParts()}>
|
||||
{(part) => (
|
||||
<Markdown
|
||||
content={part.content}
|
||||
isDark={/* get from theme context */}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
```
|
||||
|
||||
### Step 6: Add Markdown Styles
|
||||
|
||||
Add to `src/index.css`:
|
||||
|
||||
```css
|
||||
/* Markdown prose styles */
|
||||
.prose {
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
@apply bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
@apply text-blue-600 dark:text-blue-400 hover:underline;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
@apply border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@apply text-2xl font-bold mb-4;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@apply text-xl font-bold mb-3;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
@apply text-lg font-bold mb-2;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
@apply border-collapse w-full;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
@apply border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.prose td {
|
||||
@apply border border-gray-300 dark:border-gray-700 px-4 py-2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Handle Theme Changes
|
||||
|
||||
Create or update theme context to track light/dark mode:
|
||||
|
||||
```typescript
|
||||
import { createContext, createSignal, useContext } from 'solid-js'
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
isDark: () => boolean
|
||||
toggleTheme: () => void
|
||||
}>()
|
||||
|
||||
export function ThemeProvider(props: { children: any }) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const [isDark, setIsDark] = createSignal(prefersDark)
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDark(!isDark())
|
||||
document.documentElement.classList.toggle('dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
|
||||
{props.children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Test Markdown Rendering
|
||||
|
||||
Test with various markdown inputs:
|
||||
|
||||
1. **Headings**: `# Heading 1\n## Heading 2`
|
||||
2. **Code blocks**: ` ```typescript\nconst x = 1\n``` `
|
||||
3. **Inline code**: `` `npm install` ``
|
||||
4. **Lists**: `- Item 1\n- Item 2`
|
||||
5. **Links**: `[OpenCode](https://opencode.ai)`
|
||||
6. **Bold/Italic**: `**bold** and *italic*`
|
||||
7. **Blockquotes**: `> Quote`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Markdown content renders with proper formatting
|
||||
- [ ] Code blocks have syntax highlighting
|
||||
- [ ] Light and dark themes work correctly
|
||||
- [ ] Copy button appears on code block hover
|
||||
- [ ] Copy button successfully copies code to clipboard
|
||||
- [ ] Language label shows for code blocks
|
||||
- [ ] Inline code has distinct styling
|
||||
- [ ] Links open in external browser
|
||||
- [ ] No XSS vulnerabilities (sanitized output)
|
||||
- [ ] Theme changes update code highlighting
|
||||
- [ ] Headings, lists, blockquotes render correctly
|
||||
- [ ] Performance is acceptable (no lag when rendering)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Test all markdown syntax types
|
||||
- [ ] Test code blocks with various languages
|
||||
- [ ] Test switching between light and dark mode
|
||||
- [ ] Test copy functionality
|
||||
- [ ] Test external link opening
|
||||
- [ ] Test very long code blocks (scrolling)
|
||||
- [ ] Test malformed markdown
|
||||
- [ ] Test HTML in markdown (should be escaped)
|
||||
|
||||
## Notes
|
||||
|
||||
- Shiki loads language grammars asynchronously, so first render may be slower
|
||||
- Consider caching rendered markdown if re-rendering same content
|
||||
- For MVP, don't implement line numbers or advanced code block features
|
||||
- Keep the language list limited to common ones to reduce bundle size
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Line numbers in code blocks
|
||||
- Code block diff highlighting
|
||||
- Collapsible long code blocks
|
||||
- Search within code blocks
|
||||
- More language support
|
||||
- Custom syntax themes
|
||||
- LaTeX/Math rendering
|
||||
- Mermaid diagram support
|
||||
479
tasks/done/013-logs-tab.md
Normal file
479
tasks/done/013-logs-tab.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Task 013: Logs Tab
|
||||
|
||||
**Status:** Todo
|
||||
**Estimated Time:** 2-3 hours
|
||||
**Phase:** 3 - Essential Features
|
||||
**Dependencies:** 006 (Instance & Session Tabs)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a dedicated "Logs" tab for each instance that displays real-time server logs (stdout/stderr). This provides visibility into what the OpenCode server is doing and helps with debugging.
|
||||
|
||||
## Context
|
||||
|
||||
Currently, server logs are captured but not displayed anywhere. Users need to see:
|
||||
|
||||
- Server startup messages
|
||||
- Port information
|
||||
- Error messages
|
||||
- Debug output
|
||||
- Any other stdout/stderr from the OpenCode server
|
||||
|
||||
The Logs tab should be a special tab that appears alongside session tabs and cannot be closed.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **Logs Tab Appearance**
|
||||
- Appears in session tabs area (Level 2 tabs)
|
||||
- Label: "Logs"
|
||||
- Icon: Terminal icon (⚡ or similar)
|
||||
- Non-closable (no × button)
|
||||
- Always present for each instance
|
||||
- Typically positioned at the end of session tabs
|
||||
|
||||
2. **Log Display**
|
||||
- Shows all stdout/stderr from server process
|
||||
- Real-time updates as logs come in
|
||||
- Scrollable content
|
||||
- Auto-scroll to bottom when new logs arrive
|
||||
- Manual scroll up disables auto-scroll
|
||||
- Monospace font for log content
|
||||
- Timestamps for each log entry
|
||||
|
||||
3. **Log Entry Format**
|
||||
- Timestamp (HH:MM:SS)
|
||||
- Log level indicator (if available)
|
||||
- Message content
|
||||
- Color coding by level:
|
||||
- Info: Default color
|
||||
- Error: Red
|
||||
- Warning: Yellow
|
||||
- Debug: Gray/muted
|
||||
|
||||
4. **Log Controls**
|
||||
- Clear logs button
|
||||
- Scroll to bottom button (when scrolled up)
|
||||
- Optional: Filter by log level (post-MVP)
|
||||
- Optional: Search in logs (post-MVP)
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
1. **State Management**
|
||||
- Store logs in instance state
|
||||
- Structure: `{ timestamp: number, level: string, message: string }[]`
|
||||
- Limit log entries to prevent memory issues (e.g., max 1000 entries)
|
||||
- Old entries removed when limit reached (FIFO)
|
||||
|
||||
2. **IPC Communication**
|
||||
- Main process captures process stdout/stderr
|
||||
- Send logs to renderer via IPC events
|
||||
- Event type: `instance:log`
|
||||
- Payload: `{ instanceId: string, entry: LogEntry }`
|
||||
|
||||
3. **Rendering**
|
||||
- Virtualize log list only if performance issues (not for MVP)
|
||||
- Simple list rendering is fine for MVP
|
||||
- Each log entry is a separate div
|
||||
- Apply styling based on log level
|
||||
|
||||
4. **Performance**
|
||||
- Don't render logs when tab is not active
|
||||
- Lazy render log entries (only visible ones if using virtual scrolling - not needed for MVP)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update Instance State
|
||||
|
||||
Update `src/stores/instances.ts` to include logs:
|
||||
|
||||
```typescript
|
||||
interface LogEntry {
|
||||
timestamp: number
|
||||
level: "info" | "error" | "warn" | "debug"
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: InstanceStatus
|
||||
client: OpenCodeClient
|
||||
eventSource: EventSource | null
|
||||
sessions: Map<string, Session>
|
||||
activeSessionId: string | null
|
||||
logs: LogEntry[] // Add this
|
||||
}
|
||||
|
||||
// Add log management functions
|
||||
function addLog(instanceId: string, entry: LogEntry) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
instance.logs.push(entry)
|
||||
|
||||
// Limit to 1000 entries
|
||||
if (instance.logs.length > 1000) {
|
||||
instance.logs.shift()
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs(instanceId: string) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
instance.logs = []
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Update Main Process Log Capture
|
||||
|
||||
Update `electron/main/process-manager.ts` to send logs via IPC:
|
||||
|
||||
```typescript
|
||||
import { BrowserWindow } from "electron"
|
||||
|
||||
function spawn(folder: string, mainWindow: BrowserWindow): Promise<ProcessInfo> {
|
||||
const proc = spawn("opencode", ["serve", "--port", "0"], {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const instanceId = generateId()
|
||||
|
||||
// Capture stdout
|
||||
proc.stdout?.on("data", (data) => {
|
||||
const message = data.toString()
|
||||
|
||||
// Send to renderer
|
||||
mainWindow.webContents.send("instance:log", {
|
||||
instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: "info",
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
|
||||
// Parse port if present
|
||||
const port = parsePort(message)
|
||||
if (port) {
|
||||
// ... existing port handling
|
||||
}
|
||||
})
|
||||
|
||||
// Capture stderr
|
||||
proc.stderr?.on("data", (data) => {
|
||||
const message = data.toString()
|
||||
|
||||
mainWindow.webContents.send("instance:log", {
|
||||
instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: "error",
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ... rest of spawn logic
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Preload Script
|
||||
|
||||
Add IPC handler in `electron/preload/index.ts`:
|
||||
|
||||
```typescript
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// ... existing methods
|
||||
|
||||
onInstanceLog: (callback: (data: { instanceId: string; entry: LogEntry }) => void) => {
|
||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Step 4: Create Logs Component
|
||||
|
||||
Create `src/components/logs-view.tsx`:
|
||||
|
||||
```typescript
|
||||
import { For, createSignal, createEffect, onMount } from 'solid-js'
|
||||
import { useInstances } from '../stores/instances'
|
||||
|
||||
interface LogsViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
export function LogsView(props: LogsViewProps) {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const instances = useInstances()
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = () => instance()?.logs ?? []
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
// Handle manual scroll
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom =
|
||||
scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
// Call store method to clear logs
|
||||
instances.clearLogs(props.instanceId)
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error': return 'text-red-600 dark:text-red-400'
|
||||
case 'warn': return 'text-yellow-600 dark:text-yellow-400'
|
||||
case 'debug': return 'text-gray-500 dark:text-gray-500'
|
||||
default: return 'text-gray-900 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
{/* Header with controls */}
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Server Logs
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onClick={clearLogs}
|
||||
class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs container */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs"
|
||||
>
|
||||
{logs().length === 0 ? (
|
||||
<div class="text-gray-500 dark:text-gray-500 text-center py-8">
|
||||
Waiting for server output...
|
||||
</div>
|
||||
) : (
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="flex gap-2 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<span class="text-gray-500 dark:text-gray-500 select-none">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span class={getLevelColor(entry.level)}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{!autoScroll() && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="absolute bottom-4 right-4 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700"
|
||||
>
|
||||
↓ Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Session Tabs Component
|
||||
|
||||
Update `src/components/session-tabs.tsx` to include Logs tab:
|
||||
|
||||
```typescript
|
||||
import { LogsView } from './logs-view'
|
||||
|
||||
export function SessionTabs(props: { instanceId: string }) {
|
||||
const sessions = () => getSessionsForInstance(props.instanceId)
|
||||
const activeSession = () => getActiveSession(props.instanceId)
|
||||
const [activeTab, setActiveTab] = createSignal<string | 'logs'>(/* ... */)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
{/* Tab headers */}
|
||||
<div class="flex items-center border-b border-gray-200 dark:border-gray-700">
|
||||
{/* Session tabs */}
|
||||
<For each={sessions()}>
|
||||
{(session) => (
|
||||
<button
|
||||
onClick={() => setActiveTab(session.id)}
|
||||
class={/* ... */}
|
||||
>
|
||||
{session.title || 'Untitled'}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Logs tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
class={`px-4 py-2 text-sm ${
|
||||
activeTab() === 'logs'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
⚡ Logs
|
||||
</button>
|
||||
|
||||
{/* New session button */}
|
||||
<button class="px-3 py-2 text-gray-500 hover:text-gray-700">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{activeTab() === 'logs' ? (
|
||||
<LogsView instanceId={props.instanceId} />
|
||||
) : (
|
||||
<SessionView sessionId={activeTab()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Setup IPC Listener
|
||||
|
||||
In `src/App.tsx` or wherever instances are initialized:
|
||||
|
||||
```typescript
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
onMount(() => {
|
||||
// Listen for log events from main process
|
||||
window.electronAPI.onInstanceLog((data) => {
|
||||
const { instanceId, entry } = data
|
||||
instances.addLog(instanceId, entry)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Step 7: Add Initial Server Logs
|
||||
|
||||
When instance starts, add a startup log:
|
||||
|
||||
```typescript
|
||||
function createInstance(folder: string) {
|
||||
const instanceId = generateId()
|
||||
|
||||
// Add initial log
|
||||
instances.addLog(instanceId, {
|
||||
timestamp: Date.now(),
|
||||
level: "info",
|
||||
message: `Starting OpenCode server for ${folder}...`,
|
||||
})
|
||||
|
||||
// ... spawn server
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Test Logs Display
|
||||
|
||||
1. Start an instance
|
||||
2. Switch to Logs tab
|
||||
3. Verify startup messages appear
|
||||
4. Verify real-time updates
|
||||
5. Test auto-scroll behavior
|
||||
6. Test clear button
|
||||
7. Test manual scroll disables auto-scroll
|
||||
8. Test scroll to bottom button
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Logs tab appears for each instance
|
||||
- [ ] Logs tab has terminal icon
|
||||
- [ ] Logs tab cannot be closed
|
||||
- [ ] Server stdout displays in real-time
|
||||
- [ ] Server stderr displays in real-time
|
||||
- [ ] Logs have timestamps
|
||||
- [ ] Error logs are red
|
||||
- [ ] Warning logs are yellow
|
||||
- [ ] Auto-scroll works when at bottom
|
||||
- [ ] Manual scroll disables auto-scroll
|
||||
- [ ] Scroll to bottom button appears when scrolled up
|
||||
- [ ] Clear button removes all logs
|
||||
- [ ] Logs are limited to 1000 entries
|
||||
- [ ] Monospace font used for log content
|
||||
- [ ] Empty state shows when no logs
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Test with normal server startup
|
||||
- [ ] Test with server errors (e.g., port in use)
|
||||
- [ ] Test with rapid log output (stress test)
|
||||
- [ ] Test switching between session and logs tab
|
||||
- [ ] Test clearing logs
|
||||
- [ ] Test auto-scroll with new logs
|
||||
- [ ] Test manual scroll behavior
|
||||
- [ ] Test logs persist when switching instances
|
||||
- [ ] Test logs cleared when instance closes
|
||||
- [ ] Test very long log messages (wrapping)
|
||||
|
||||
## Notes
|
||||
|
||||
- For MVP, don't implement log filtering or search
|
||||
- Keep log entry limit reasonable (1000 entries)
|
||||
- Don't virtualize unless performance issues
|
||||
- Consider adding log levels based on OpenCode server output format
|
||||
- May need to parse ANSI color codes if server uses them
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Filter logs by level (info, error, warn, debug)
|
||||
- Search within logs
|
||||
- Export logs to file
|
||||
- Copy log entry on click
|
||||
- Follow mode toggle (auto-scroll on/off)
|
||||
- Parse and highlight errors/stack traces
|
||||
- ANSI color code support
|
||||
- Log level indicators with icons
|
||||
- Timestamps toggle
|
||||
- Word wrap toggle
|
||||
849
tasks/done/015-keyboard-shortcuts.md
Normal file
849
tasks/done/015-keyboard-shortcuts.md
Normal file
@@ -0,0 +1,849 @@
|
||||
# Task 015: Keyboard Shortcuts
|
||||
|
||||
## Goal
|
||||
|
||||
Implement comprehensive keyboard shortcuts for efficient keyboard-first navigation, inspired by the TUI's keyboard system but adapted for desktop multi-instance/multi-session workflow.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ 001-013 completed
|
||||
- ✅ All core UI components built
|
||||
- ✅ Message stream, prompt input, tabs working
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. **Tab Navigation**: Use `Cmd/Ctrl+[/]` for instances, `Cmd/Ctrl+Shift+[/]` for sessions
|
||||
2. **Clear Input**: Use `Cmd/Ctrl+K` (common in Slack, Discord, VS Code)
|
||||
3. **Escape Behavior**: Context-dependent (blur when idle, interrupt when busy)
|
||||
4. **Message History**: Per-instance, stored in IndexedDB (embedded local database)
|
||||
5. **Agent Cycling**: Include Tab/Shift+Tab for agent cycling, add model selector focus shortcut
|
||||
6. **Leader Key**: Skip it - use standard Cmd/Ctrl patterns
|
||||
7. **Platform**: Cmd on macOS, Ctrl elsewhere (standard cross-platform pattern)
|
||||
8. **View Controls**: Not needed for MVP
|
||||
9. **Help Dialog**: Not needed - inline hints instead
|
||||
|
||||
## Key Principles
|
||||
|
||||
### Smart Inline Hints
|
||||
|
||||
Instead of a help dialog, show shortcuts contextually:
|
||||
|
||||
- Display hints next to actions they affect
|
||||
- Keep hints subtle (small text, muted color)
|
||||
- Use platform-specific symbols (⌘ on Mac, Ctrl elsewhere)
|
||||
- Examples already in app: "Enter to send • Shift+Enter for new line"
|
||||
|
||||
### Modular Architecture
|
||||
|
||||
Build shortcuts in a centralized, configurable system:
|
||||
|
||||
- Single source of truth for all shortcuts
|
||||
- Easy to extend for future customization
|
||||
- Clear separation between shortcut definition and handler logic
|
||||
- Registry pattern for discoverability
|
||||
|
||||
## Shortcuts to Implement
|
||||
|
||||
### Navigation (Tabs)
|
||||
|
||||
**Already Implemented:**
|
||||
|
||||
- [x] `Cmd/Ctrl+1-9` - Switch to instance tab by index
|
||||
- [x] `Cmd/Ctrl+N` - New instance (select folder)
|
||||
- [x] `Cmd/Ctrl+T` - New session in active instance
|
||||
- [x] `Cmd/Ctrl+W` - Close active **parent** session (only)
|
||||
|
||||
**To Implement:**
|
||||
|
||||
- [ ] `Cmd/Ctrl+[` - Previous instance tab
|
||||
- [ ] `Cmd/Ctrl+]` - Next instance tab
|
||||
- [ ] `Cmd/Ctrl+Shift+[` - Previous session tab
|
||||
- [ ] `Cmd/Ctrl+Shift+]` - Next session tab
|
||||
- [ ] `Cmd/Ctrl+Shift+L` - Switch to Logs tab
|
||||
|
||||
### Input Management
|
||||
|
||||
**Already Implemented:**
|
||||
|
||||
- [x] `Enter` - Send message
|
||||
- [x] `Shift+Enter` - New line
|
||||
|
||||
**To Implement:**
|
||||
|
||||
- [ ] `Cmd/Ctrl+K` - Clear input
|
||||
- [ ] `Cmd/Ctrl+L` - Focus prompt input
|
||||
- [ ] `Up Arrow` - Previous message in history (when at start of input)
|
||||
- [ ] `Down Arrow` - Next message in history (when in history mode)
|
||||
- [ ] `Escape` - Context-dependent:
|
||||
- When idle: Blur input / close modals
|
||||
- When busy: Interrupt session (requires confirmation)
|
||||
|
||||
### Agent/Model Selection
|
||||
|
||||
**To Implement:**
|
||||
|
||||
- [ ] `Tab` - Cycle to next agent (when input empty or not focused)
|
||||
- [ ] `Shift+Tab` - Cycle to previous agent
|
||||
- [ ] `Cmd/Ctrl+M` - Focus model selector dropdown
|
||||
|
||||
### Message Navigation
|
||||
|
||||
**To Implement:**
|
||||
|
||||
- [ ] `PgUp` - Scroll messages up
|
||||
- [ ] `PgDown` - Scroll messages down
|
||||
- [ ] `Home` - Jump to first message
|
||||
- [ ] `End` - Jump to last message
|
||||
|
||||
## Architecture Design
|
||||
|
||||
### 1. Centralized Keyboard Registry
|
||||
|
||||
```typescript
|
||||
// src/lib/keyboard-registry.ts
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string
|
||||
key: string
|
||||
modifiers: {
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
handler: () => void
|
||||
description: string
|
||||
context?: "global" | "input" | "messages" // Where it works
|
||||
condition?: () => boolean // Runtime condition check
|
||||
}
|
||||
|
||||
class KeyboardRegistry {
|
||||
private shortcuts = new Map<string, KeyboardShortcut>()
|
||||
|
||||
register(shortcut: KeyboardShortcut) {
|
||||
this.shortcuts.set(shortcut.id, shortcut)
|
||||
}
|
||||
|
||||
unregister(id: string) {
|
||||
this.shortcuts.delete(id)
|
||||
}
|
||||
|
||||
findMatch(event: KeyboardEvent): KeyboardShortcut | null {
|
||||
for (const shortcut of this.shortcuts.values()) {
|
||||
if (this.matches(event, shortcut)) {
|
||||
// Check context
|
||||
if (shortcut.context === "input" && !this.isInputFocused()) continue
|
||||
if (shortcut.context === "messages" && this.isInputFocused()) continue
|
||||
|
||||
// Check runtime condition
|
||||
if (shortcut.condition && !shortcut.condition()) continue
|
||||
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private matches(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
|
||||
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||
const ctrlMatch = event.ctrlKey === !!shortcut.modifiers.ctrl
|
||||
const metaMatch = event.metaKey === !!shortcut.modifiers.meta
|
||||
const shiftMatch = event.shiftKey === !!shortcut.modifiers.shift
|
||||
const altMatch = event.altKey === !!shortcut.modifiers.alt
|
||||
|
||||
return keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch
|
||||
}
|
||||
|
||||
private isInputFocused(): boolean {
|
||||
const active = document.activeElement
|
||||
return active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable")
|
||||
}
|
||||
|
||||
getByContext(context: string): KeyboardShortcut[] {
|
||||
return Array.from(this.shortcuts.values()).filter((s) => !s.context || s.context === context)
|
||||
}
|
||||
}
|
||||
|
||||
export const keyboardRegistry = new KeyboardRegistry()
|
||||
```
|
||||
|
||||
### 2. Cross-Platform Key Helper
|
||||
|
||||
```typescript
|
||||
// src/lib/keyboard-utils.ts
|
||||
|
||||
export const isMac = () => navigator.platform.includes("Mac")
|
||||
|
||||
export const modKey = (event?: KeyboardEvent) => {
|
||||
if (!event) return isMac() ? "metaKey" : "ctrlKey"
|
||||
return isMac() ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
export const modKeyPressed = (event: KeyboardEvent) => {
|
||||
return isMac() ? event.metaKey : event.ctrlKey
|
||||
}
|
||||
|
||||
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
|
||||
const parts: string[] = []
|
||||
|
||||
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
|
||||
parts.push(isMac() ? "⌘" : "Ctrl")
|
||||
}
|
||||
if (shortcut.modifiers.shift) {
|
||||
parts.push(isMac() ? "⇧" : "Shift")
|
||||
}
|
||||
if (shortcut.modifiers.alt) {
|
||||
parts.push(isMac() ? "⌥" : "Alt")
|
||||
}
|
||||
|
||||
parts.push(shortcut.key.toUpperCase())
|
||||
|
||||
return parts.join(isMac() ? "" : "+")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. IndexedDB Storage Layer
|
||||
|
||||
```typescript
|
||||
// src/lib/db.ts
|
||||
|
||||
const DB_NAME = "opencode-client"
|
||||
const DB_VERSION = 1
|
||||
const HISTORY_STORE = "message-history"
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
async function getDB(): Promise<IDBDatabase> {
|
||||
if (db) return db
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
// Create object stores
|
||||
if (!db.objectStoreNames.contains(HISTORY_STORE)) {
|
||||
db.createObjectStore(HISTORY_STORE)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveHistory(instanceId: string, history: string[]): Promise<void> {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readwrite")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.put(history, instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadHistory(instanceId: string): Promise<string[]> {
|
||||
try {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readonly")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.get(instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result || [])
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history from IndexedDB:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistory(instanceId: string): Promise<void> {
|
||||
const database = await getDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(HISTORY_STORE, "readwrite")
|
||||
const store = tx.objectStore(HISTORY_STORE)
|
||||
const request = store.delete(instanceId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Message History Management
|
||||
|
||||
Per-instance storage using IndexedDB (persists across app restarts):
|
||||
|
||||
```typescript
|
||||
// src/stores/message-history.ts
|
||||
|
||||
import { saveHistory, loadHistory, deleteHistory } from "../lib/db"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
// In-memory cache
|
||||
const instanceHistories = new Map<string, string[]>()
|
||||
const historyLoaded = new Set<string>()
|
||||
|
||||
export async function addToHistory(instanceId: string, text: string): Promise<void> {
|
||||
// Ensure history is loaded
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
|
||||
const history = instanceHistories.get(instanceId) || []
|
||||
|
||||
// Add to front (newest first)
|
||||
history.unshift(text)
|
||||
|
||||
// Limit to MAX_HISTORY
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.length = MAX_HISTORY
|
||||
}
|
||||
|
||||
// Update cache and persist
|
||||
instanceHistories.set(instanceId, history)
|
||||
|
||||
// Persist to IndexedDB (async, don't wait)
|
||||
saveHistory(instanceId, history).catch((err) => {
|
||||
console.warn("Failed to persist message history:", err)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getHistory(instanceId: string): Promise<string[]> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
return instanceHistories.get(instanceId) || []
|
||||
}
|
||||
|
||||
export async function clearHistory(instanceId: string): Promise<void> {
|
||||
// Manually clear history (not called on instance stop)
|
||||
instanceHistories.delete(instanceId)
|
||||
historyLoaded.delete(instanceId)
|
||||
await deleteHistory(instanceId)
|
||||
}
|
||||
|
||||
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
if (historyLoaded.has(instanceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await loadHistory(instanceId)
|
||||
instanceHistories.set(instanceId, history)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
instanceHistories.set(instanceId, [])
|
||||
historyLoaded.add(instanceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Inline Hint Component
|
||||
|
||||
```typescript
|
||||
// src/components/keyboard-hint.tsx
|
||||
|
||||
import { Component } from 'solid-js'
|
||||
import { formatShortcut, type KeyboardShortcut } from '../lib/keyboard-utils'
|
||||
|
||||
const KeyboardHint: Component<{
|
||||
shortcuts: KeyboardShortcut[]
|
||||
separator?: string
|
||||
}> = (props) => {
|
||||
return (
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{props.shortcuts.map((shortcut, i) => (
|
||||
<>
|
||||
{i > 0 && <span class="mx-1">{props.separator || '•'}</span>}
|
||||
<kbd class="font-mono">{formatShortcut(shortcut)}</kbd>
|
||||
<span class="ml-1">{shortcut.description}</span>
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default KeyboardHint
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create Keyboard Infrastructure
|
||||
|
||||
1. Create `src/lib/keyboard-registry.ts` - Central registry
|
||||
2. Create `src/lib/keyboard-utils.ts` - Platform helpers
|
||||
3. Create `src/lib/db.ts` - IndexedDB storage layer
|
||||
4. Create `src/stores/message-history.ts` - History management
|
||||
5. Create `src/components/keyboard-hint.tsx` - Inline hints component
|
||||
|
||||
### Step 2: Register Navigation Shortcuts
|
||||
|
||||
```typescript
|
||||
// src/lib/shortcuts/navigation.ts
|
||||
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import { getSessions, activeSessionId, setActiveSession } from "../../stores/sessions"
|
||||
|
||||
export function registerNavigationShortcuts() {
|
||||
// Instance navigation
|
||||
keyboardRegistry.register({
|
||||
id: "instance-prev",
|
||||
key: "[",
|
||||
modifiers: { ctrl: true, meta: true },
|
||||
handler: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const prev = current === 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
||||
},
|
||||
description: "previous instance",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "instance-next",
|
||||
key: "]",
|
||||
modifiers: { ctrl: true, meta: true },
|
||||
handler: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveInstanceId(ids[next])
|
||||
},
|
||||
description: "next instance",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
// Session navigation
|
||||
keyboardRegistry.register({
|
||||
id: "session-prev",
|
||||
key: "[",
|
||||
modifiers: { ctrl: true, meta: true, shift: true },
|
||||
handler: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
|
||||
const sessions = getSessions(instanceId)
|
||||
const ids = sessions.map((s) => s.id).concat(["logs"])
|
||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||
const prev = current === 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
||||
},
|
||||
description: "previous session",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "session-next",
|
||||
key: "]",
|
||||
modifiers: { ctrl: true, meta: true, shift: true },
|
||||
handler: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
|
||||
const sessions = getSessions(instanceId)
|
||||
const ids = sessions.map((s) => s.id).concat(["logs"])
|
||||
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
||||
},
|
||||
description: "next session",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
// Logs tab
|
||||
keyboardRegistry.register({
|
||||
id: "switch-to-logs",
|
||||
key: "l",
|
||||
modifiers: { ctrl: true, meta: true, shift: true },
|
||||
handler: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) setActiveSession(instanceId, "logs")
|
||||
},
|
||||
description: "logs tab",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Register Input Shortcuts
|
||||
|
||||
```typescript
|
||||
// src/lib/shortcuts/input.ts
|
||||
|
||||
export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) {
|
||||
keyboardRegistry.register({
|
||||
id: "clear-input",
|
||||
key: "k",
|
||||
modifiers: { ctrl: true, meta: true },
|
||||
handler: clearInput,
|
||||
description: "clear input",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-input",
|
||||
key: "l",
|
||||
modifiers: { ctrl: true, meta: true },
|
||||
handler: focusInput,
|
||||
description: "focus input",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update PromptInput with History Navigation
|
||||
|
||||
```typescript
|
||||
// src/components/prompt-input.tsx
|
||||
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
import { addToHistory, getHistory } from '../stores/message-history'
|
||||
|
||||
const PromptInput: Component<Props> = (props) => {
|
||||
const [input, setInput] = createSignal('')
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
|
||||
// Load history on mount
|
||||
onMount(async () => {
|
||||
const loaded = await getHistory(props.instanceId)
|
||||
setHistory(loaded)
|
||||
})
|
||||
|
||||
async function handleKeyDown(e: KeyboardEvent) {
|
||||
const textarea = textareaRef
|
||||
if (!textarea) return
|
||||
|
||||
const atStart = textarea.selectionStart === 0
|
||||
const currentHistory = history()
|
||||
|
||||
// Up arrow - navigate to older message
|
||||
if (e.key === 'ArrowUp' && atStart && currentHistory.length > 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(historyIndex() + 1, currentHistory.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setInput(currentHistory[newIndex])
|
||||
}
|
||||
|
||||
// Down arrow - navigate to newer message
|
||||
if (e.key === 'ArrowDown' && historyIndex() >= 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setInput(currentHistory[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = input().trim()
|
||||
if (!text) return
|
||||
|
||||
// Add to history (async, per instance)
|
||||
await addToHistory(props.instanceId, text)
|
||||
|
||||
// Reload history for next navigation
|
||||
const updated = await getHistory(props.instanceId)
|
||||
setHistory(updated)
|
||||
setHistoryIndex(-1)
|
||||
|
||||
await props.onSend(text)
|
||||
setInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="prompt-input">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
|
||||
<KeyboardHint shortcuts={[
|
||||
{ description: 'to send', key: 'Enter', modifiers: {} },
|
||||
{ description: 'for new line', key: 'Enter', modifiers: { shift: true } }
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Agent Cycling
|
||||
|
||||
```typescript
|
||||
// src/lib/shortcuts/agent.ts
|
||||
|
||||
export function registerAgentShortcuts(
|
||||
cycleAgent: () => void,
|
||||
cycleAgentReverse: () => void,
|
||||
focusModelSelector: () => void,
|
||||
) {
|
||||
keyboardRegistry.register({
|
||||
id: "agent-next",
|
||||
key: "Tab",
|
||||
modifiers: {},
|
||||
handler: cycleAgent,
|
||||
description: "next agent",
|
||||
context: "global",
|
||||
condition: () => !isInputFocused(), // Only when not typing
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "agent-prev",
|
||||
key: "Tab",
|
||||
modifiers: { shift: true },
|
||||
handler: cycleAgentReverse,
|
||||
description: "previous agent",
|
||||
context: "global",
|
||||
condition: () => !isInputFocused(),
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-model",
|
||||
key: "m",
|
||||
modifiers: { ctrl: true, meta: true },
|
||||
handler: focusModelSelector,
|
||||
description: "focus model",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Escape Key Context Handling
|
||||
|
||||
```typescript
|
||||
// src/lib/shortcuts/escape.ts
|
||||
|
||||
export function registerEscapeShortcut(
|
||||
isSessionBusy: () => boolean,
|
||||
interruptSession: () => void,
|
||||
blurInput: () => void,
|
||||
closeModal: () => void,
|
||||
) {
|
||||
keyboardRegistry.register({
|
||||
id: "escape",
|
||||
key: "Escape",
|
||||
modifiers: {},
|
||||
handler: () => {
|
||||
// Priority 1: Close modal if open
|
||||
if (hasOpenModal()) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
|
||||
// Priority 2: Interrupt if session is busy
|
||||
if (isSessionBusy()) {
|
||||
interruptSession()
|
||||
return
|
||||
}
|
||||
|
||||
// Priority 3: Blur input
|
||||
blurInput()
|
||||
},
|
||||
description: "cancel/close",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Setup Global Listener in App
|
||||
|
||||
```typescript
|
||||
// src/App.tsx
|
||||
|
||||
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
||||
import { registerInputShortcuts } from "./lib/shortcuts/input"
|
||||
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
|
||||
import { registerEscapeShortcut } from "./lib/shortcuts/escape"
|
||||
import { keyboardRegistry } from "./lib/keyboard-registry"
|
||||
|
||||
onMount(() => {
|
||||
// Register all shortcuts
|
||||
registerNavigationShortcuts()
|
||||
registerInputShortcuts(
|
||||
() => setInput(""),
|
||||
() => document.querySelector("textarea")?.focus(),
|
||||
)
|
||||
registerAgentShortcuts(handleCycleAgent, handleCycleAgentReverse, () =>
|
||||
document.querySelector("[data-model-selector]")?.focus(),
|
||||
)
|
||||
registerEscapeShortcut(
|
||||
() => activeInstance()?.status === "streaming",
|
||||
handleInterrupt,
|
||||
() => document.activeElement?.blur(),
|
||||
hideModal,
|
||||
)
|
||||
|
||||
// Global keydown handler
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const shortcut = keyboardRegistry.findMatch(e)
|
||||
if (shortcut) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Step 8: Add Inline Hints Throughout UI
|
||||
|
||||
**In PromptInput:**
|
||||
|
||||
```tsx
|
||||
<KeyboardHint
|
||||
shortcuts={[getShortcut("enter-to-send"), getShortcut("shift-enter-newline"), getShortcut("cmd-k-clear")]}
|
||||
/>
|
||||
```
|
||||
|
||||
**In Instance Tabs:**
|
||||
|
||||
```tsx
|
||||
<KeyboardHint shortcuts={[getShortcut("cmd-1-9"), getShortcut("cmd-brackets")]} />
|
||||
```
|
||||
|
||||
**In Agent Selector:**
|
||||
|
||||
```tsx
|
||||
<KeyboardHint shortcuts={[getShortcut("tab-cycle")]} />
|
||||
```
|
||||
|
||||
## Where to Show Hints
|
||||
|
||||
1. **Prompt Input Area** (bottom)
|
||||
- Enter/Shift+Enter (already shown)
|
||||
- Add: Cmd+K to clear, ↑↓ for history
|
||||
|
||||
2. **Instance Tabs** (subtle tooltip or header)
|
||||
- Cmd+1-9, Cmd+[/]
|
||||
|
||||
3. **Session Tabs** (same as instance)
|
||||
- Cmd+Shift+[/]
|
||||
|
||||
4. **Agent/Model Selectors** (placeholder or label)
|
||||
- Tab/Shift+Tab, Cmd+M
|
||||
|
||||
5. **Empty State** (when no messages)
|
||||
- Common shortcuts overview
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Navigation
|
||||
|
||||
- [ ] Cmd/Ctrl+[ / ] cycles instance tabs
|
||||
- [ ] Cmd/Ctrl+Shift+[ / ] cycles session tabs
|
||||
- [ ] Cmd/Ctrl+1-9 jumps to instance
|
||||
- [ ] Cmd/Ctrl+T creates new session
|
||||
- [ ] Cmd/Ctrl+W closes parent session only
|
||||
- [ ] Cmd/Ctrl+Shift+L switches to logs
|
||||
|
||||
### Input
|
||||
|
||||
- [ ] Cmd/Ctrl+K clears input
|
||||
- [ ] Cmd/Ctrl+L focuses input
|
||||
- [ ] Up arrow loads previous message (when at start)
|
||||
- [ ] Down arrow navigates forward in history
|
||||
- [ ] History is per-instance
|
||||
- [ ] History persists in IndexedDB across app restarts
|
||||
- [ ] History limited to 100 entries (not 50)
|
||||
- [ ] History loads on component mount
|
||||
- [ ] History NOT cleared when instance stops
|
||||
|
||||
### Agent/Model
|
||||
|
||||
- [ ] Tab cycles agents (when not in input)
|
||||
- [ ] Shift+Tab cycles agents backward
|
||||
- [ ] Cmd/Ctrl+M focuses model selector
|
||||
|
||||
### Context Behavior
|
||||
|
||||
- [ ] Escape closes modals first
|
||||
- [ ] Escape interrupts when busy
|
||||
- [ ] Escape blurs input when idle
|
||||
- [ ] Shortcuts don't fire in wrong context
|
||||
|
||||
### Cross-Platform
|
||||
|
||||
- [ ] Works with Cmd on macOS
|
||||
- [ ] Works with Ctrl on Windows
|
||||
- [ ] Works with Ctrl on Linux
|
||||
- [ ] Hints show correct keys per platform
|
||||
|
||||
### Inline Hints
|
||||
|
||||
- [ ] Hints visible but not intrusive
|
||||
- [ ] Correct platform symbols shown
|
||||
- [ ] Hints appear in relevant locations
|
||||
- [ ] No excessive screen space used
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Requires:**
|
||||
|
||||
- Tasks 001-013 completed
|
||||
|
||||
**Blocks:**
|
||||
|
||||
- None (final MVP task)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
4-5 hours
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ Task complete when:
|
||||
|
||||
- All shortcuts implemented and working
|
||||
- Message history per-instance, persisted in IndexedDB
|
||||
- History stores 100 most recent prompts
|
||||
- History persists across app restarts and instance stops
|
||||
- Agent cycling with Tab/Shift+Tab
|
||||
- Context-aware Escape behavior
|
||||
- Inline hints shown throughout UI
|
||||
- Cross-platform (Cmd/Ctrl) working
|
||||
- Modular registry system for future customization
|
||||
- Can navigate entire app efficiently with keyboard
|
||||
|
||||
## Notes on History Storage
|
||||
|
||||
**Why per-instance (folder path)?**
|
||||
|
||||
- User opens same project folder multiple times → same history
|
||||
- More intuitive: history tied to project, not ephemeral instance
|
||||
- Survives instance restarts without losing context
|
||||
|
||||
**Why 100 entries?**
|
||||
|
||||
- More generous than TUI's 50
|
||||
- ~20KB per instance (100 × ~200 chars)
|
||||
- Plenty for typical usage patterns
|
||||
- Can increase later if needed
|
||||
|
||||
**Cleanup Strategy:**
|
||||
|
||||
- No automatic cleanup (history persists indefinitely)
|
||||
- Could add manual "Clear History" option in future
|
||||
- IndexedDB handles storage efficiently
|
||||
178
tasks/done/020-command-palette.md
Normal file
178
tasks/done/020-command-palette.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: Command Palette ✅
|
||||
description: Implement VSCode-style command palette with Cmd+Shift+P
|
||||
status: COMPLETED
|
||||
completed: 2024-10-23
|
||||
---
|
||||
|
||||
# Implement Command Palette ✅
|
||||
|
||||
Built a VSCode-style command palette that opens as a centered modal dialog with 19 commands organized into 5 categories.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementation Summary
|
||||
|
||||
### Commands Implemented (19 total)
|
||||
|
||||
#### **Instance (4 commands)**
|
||||
|
||||
1. ✅ **New Instance** (Cmd+N) - Open folder picker to create new instance
|
||||
2. ✅ **Close Instance** (Cmd+W) - Stop current instance's server
|
||||
3. ✅ **Next Instance** (Cmd+]) - Cycle to next instance tab
|
||||
4. ✅ **Previous Instance** (Cmd+[) - Cycle to previous instance tab
|
||||
|
||||
#### **Session (7 commands)**
|
||||
|
||||
5. ✅ **New Session** (Cmd+Shift+N) - Create a new parent session
|
||||
6. ✅ **Close Session** (Cmd+Shift+W) - Close current parent session
|
||||
7. ✅ **Switch to Logs** (Cmd+Shift+L) - Jump to logs view
|
||||
8. ✅ **Next Session** (Cmd+Shift+]) - Cycle to next session tab
|
||||
9. ✅ **Previous Session** (Cmd+Shift+[) - Cycle to previous session tab
|
||||
10. ✅ **Compact Session** - Summarize and compact current session (/compact API)
|
||||
11. ✅ **Undo Last Message** - Revert the last message (/undo API)
|
||||
|
||||
#### **Agent & Model (5 commands)**
|
||||
|
||||
12. ✅ **Next Agent** (Tab) - Cycle to next agent
|
||||
13. ✅ **Previous Agent** (Shift+Tab) - Cycle to previous agent
|
||||
14. ✅ **Open Model Selector** (Cmd+Shift+M) - Choose a different model
|
||||
15. ✅ **Open Agent Selector** - Choose a different agent
|
||||
16. ✅ **Initialize AGENTS.md** - Create or update AGENTS.md file (/init API)
|
||||
|
||||
#### **Input & Focus (1 command)**
|
||||
|
||||
17. ✅ **Clear Input** (Cmd+K) - Clear the prompt textarea
|
||||
|
||||
#### **System (2 commands)**
|
||||
|
||||
18. ✅ **Toggle Thinking Blocks** - Show/hide AI thinking process (placeholder)
|
||||
19. ✅ **Show Help** - Display keyboard shortcuts and help (placeholder)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Features Implemented
|
||||
|
||||
### Visual Design
|
||||
|
||||
- ✅ Modal dialog centered on screen with backdrop overlay
|
||||
- ✅ ~600px wide with auto height and max height
|
||||
- ✅ Search/filter input at top
|
||||
- ✅ Scrollable list of commands below
|
||||
- ✅ Each command shows: name, description, keyboard shortcut (if any)
|
||||
- ✅ Category headers for command grouping
|
||||
- ✅ Dark/light mode support
|
||||
|
||||
### Behavior
|
||||
|
||||
- ✅ Opens on `Cmd+Shift+P`
|
||||
- ✅ Closes on `Escape` or clicking outside
|
||||
- ✅ Search input is auto-focused when opened
|
||||
- ✅ Filter commands as user types (substring search by label, description, keywords, category)
|
||||
- ✅ Arrow keys navigate through filtered list
|
||||
- ✅ Enter executes selected command
|
||||
- ✅ Mouse click on command also executes it
|
||||
- ✅ Mouse hover updates selection
|
||||
- ✅ Closes automatically after command execution
|
||||
|
||||
### Command Registry
|
||||
|
||||
- ✅ Centralized command registry in `lib/commands.ts`
|
||||
- ✅ Commands organized by category
|
||||
- ✅ Keywords for better search
|
||||
- ✅ Keyboard shortcuts displayed
|
||||
- ✅ All commands connected to existing actions
|
||||
|
||||
### Integration
|
||||
|
||||
- ✅ Integrated with keyboard registry
|
||||
- ✅ Connected to instance/session management
|
||||
- ✅ Connected to SDK client for API calls
|
||||
- ✅ Connected to UI selectors (agent, model)
|
||||
- ✅ State management via `stores/command-palette.ts`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Modified
|
||||
|
||||
- `src/App.tsx` - Registered all 19 commands with categories
|
||||
- `src/components/command-palette.tsx` - Added category grouping and display
|
||||
- `src/lib/commands.ts` - Already existed with command registry
|
||||
- `src/stores/command-palette.ts` - Already existed with state management
|
||||
|
||||
---
|
||||
|
||||
## ✅ Acceptance Criteria
|
||||
|
||||
- ✅ Palette opens with `Cmd+Shift+P`
|
||||
- ✅ Search input is auto-focused
|
||||
- ✅ 19 commands are listed in 5 categories
|
||||
- ✅ Typing filters commands (case-insensitive substring match)
|
||||
- ✅ Arrow keys navigate through list
|
||||
- ✅ Enter executes selected command
|
||||
- ✅ Click executes command
|
||||
- ✅ Escape or click outside closes palette
|
||||
- ✅ Palette closes after command execution
|
||||
- ✅ Keyboard shortcuts display correctly
|
||||
- ✅ Commands execute their intended actions:
|
||||
- ✅ `/init` calls API
|
||||
- ✅ `/compact` calls API
|
||||
- ✅ `/undo` calls API
|
||||
- ✅ New Session/Instance work
|
||||
- ✅ Model/Agent selectors open
|
||||
- ✅ Navigation shortcuts work
|
||||
- ✅ Works in both light and dark mode
|
||||
- ✅ Smooth open/close animations
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Implementation Details
|
||||
|
||||
### Category Ordering
|
||||
|
||||
Commands are grouped and displayed in this order:
|
||||
|
||||
1. Instance - Managing workspace folders
|
||||
2. Session - Managing conversation sessions
|
||||
3. Agent & Model - AI configuration
|
||||
4. Input & Focus - Input controls
|
||||
5. System - System-level settings
|
||||
|
||||
### Search Functionality
|
||||
|
||||
Search filters by:
|
||||
|
||||
- Command label
|
||||
- Command description
|
||||
- Keywords
|
||||
- Category name
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
All shortcuts are registered in the keyboard registry and displayed in the palette using the `Kbd` component.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
These can be added post-MVP:
|
||||
|
||||
- Fuzzy search algorithm (not just substring)
|
||||
- Command history (recently used commands first)
|
||||
- Custom user-defined commands
|
||||
- Command arguments/parameters
|
||||
- Command aliases
|
||||
- Search by keyboard shortcut
|
||||
- Quick switch between sessions/instances via command palette
|
||||
- Command icons/emoji
|
||||
- Command grouping within categories
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Command palette provides VSCode-like discoverability
|
||||
- All commands leverage existing keyboard shortcuts and actions
|
||||
- Categories make it easy to find related commands
|
||||
- Foundation is in place for adding more commands in the future
|
||||
- Agent and Model selector commands work by programmatically clicking their triggers
|
||||
40
tasks/done/021-file-attachments.md
Normal file
40
tasks/done/021-file-attachments.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: File Attachments
|
||||
description: Add @mentions, drag & drop, and chips for files.
|
||||
---
|
||||
|
||||
Implement File Attachments
|
||||
|
||||
---
|
||||
|
||||
### Implement @ Mentions
|
||||
|
||||
When a user types `@` in the input field, display a file picker with search functionality.
|
||||
|
||||
Allow users to select files to attach to their prompt.
|
||||
|
||||
---
|
||||
|
||||
### Visual Attachment Chips
|
||||
|
||||
Display attached files as interactive chips above the input area.
|
||||
|
||||
Chips should include a filename and a removable "x" button.
|
||||
|
||||
---
|
||||
|
||||
### Drag and Drop Files
|
||||
|
||||
Enable dragging files from the operating system directly onto the input area.
|
||||
|
||||
Automatically create an attachment chip for dropped files.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Typing `@` brings up a file selection autocomplete.
|
||||
- Files can be selected and appear as chips.
|
||||
- Users can drag and drop files onto the input, creating chips.
|
||||
- Attached files are included in the prompt submission.
|
||||
- Attachment chips can be removed by clicking "x".
|
||||
29
tasks/done/022-long-paste-handling.md
Normal file
29
tasks/done/022-long-paste-handling.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Long Paste Handling
|
||||
description: Summarize large pasted text into attachments.
|
||||
---
|
||||
|
||||
Implement Long Paste Handling
|
||||
|
||||
---
|
||||
|
||||
### Detect Long Pastes
|
||||
|
||||
Monitor clipboard paste events for text content. Identify if the pasted text exceeds a defined length (e.g., >150 characters or >3 lines).
|
||||
|
||||
---
|
||||
|
||||
### Create Summarized Attachments
|
||||
|
||||
If a paste is identified as "long", prevent direct insertion into the input field. Instead, create a new text attachment containing the full content.
|
||||
|
||||
Display a summarized chip for the attachment, such as `[pasted #1 10+ lines]`.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Pasting short text directly inserts it into the input.
|
||||
- Pasting long text creates a summarized attachment chip.
|
||||
- The full content of the long paste is retained within the attachment for submission.
|
||||
- Multiple long pastes create distinct numbered chips.
|
||||
31
tasks/done/024-agent-attachments.md
Normal file
31
tasks/done/024-agent-attachments.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Agent Attachments
|
||||
description: Allow @agent mentions for multi-agent conversations.
|
||||
---
|
||||
|
||||
Implement Agent Attachments
|
||||
|
||||
---
|
||||
|
||||
### @ Agent Autocomplete
|
||||
|
||||
When a user types `@` followed by an agent name, display an autocomplete list of available agents.
|
||||
|
||||
Filter agent suggestions as the user types.
|
||||
|
||||
---
|
||||
|
||||
### Attach Agents
|
||||
|
||||
Enable users to select an agent from the autocomplete list to attach to their prompt.
|
||||
|
||||
Display attached agents as interactive chips.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Typing `@` followed by a partial agent name displays matching agent suggestions.
|
||||
- Selecting an agent creates an attachment chip.
|
||||
- Attached agents are included in the prompt submission.
|
||||
- Agent chips can be removed.
|
||||
31
tasks/done/025-image-clipboard-support.md
Normal file
31
tasks/done/025-image-clipboard-support.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Image Clipboard
|
||||
description: Support pasting images from the clipboard.
|
||||
---
|
||||
|
||||
Implement Image Clipboard Support
|
||||
|
||||
---
|
||||
|
||||
### Detect Image Paste
|
||||
|
||||
Detect when image data is present in the system clipboard during a paste event.
|
||||
|
||||
Prioritize image data over text data if both are present.
|
||||
|
||||
---
|
||||
|
||||
### Create Image Attachment
|
||||
|
||||
Automatically create an image attachment from the pasted image data. Convert the image to a base64 encoded format for internal handling and submission.
|
||||
|
||||
Display the image attachment as a chip in the input area.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Pasting an image from the clipboard creates an image attachment chip.
|
||||
- The image data is base64 encoded and associated with the attachment.
|
||||
- The attachment chip has a suitable display name (e.g., `[Image #1]`).
|
||||
- Users can clear the image attachment.
|
||||
35
tasks/done/041-tailwind-theme-hooks.md
Normal file
35
tasks/done/041-tailwind-theme-hooks.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Task 041 - Tailwind Theme Hooks
|
||||
|
||||
## Goal
|
||||
Establish the base Tailwind configuration needed for theming work without changing current visuals.
|
||||
|
||||
## Prerequisites
|
||||
- Installed project dependencies (`npm install`).
|
||||
- Ability to run the renderer locally (`npm run dev`).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `tailwind.config.js` uses `darkMode: ["class", '[data-theme="dark"]']`.
|
||||
- [ ] `theme.extend` contains empty objects for upcoming tokens: `colors`, `spacing`, `fontSize`, `borderRadius`, and `boxShadow`.
|
||||
- [ ] No other configuration changes are introduced.
|
||||
- [ ] App builds and renders exactly as before (no visual diffs expected).
|
||||
|
||||
## Steps
|
||||
1. Update `tailwind.config.js` with the new `darkMode` array value.
|
||||
2. Add empty extension objects for `colors`, `spacing`, `fontSize`, `borderRadius`, and `boxShadow` under `theme.extend`.
|
||||
3. Double-check that all other keys remain untouched.
|
||||
4. Save the file.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run dev` and ensure the renderer starts successfully.
|
||||
- [ ] Smoke test the UI in light and dark mode to confirm no visual regressions.
|
||||
|
||||
## Dependencies
|
||||
- None.
|
||||
|
||||
## Estimated Time
|
||||
0.25 hours
|
||||
|
||||
## Notes
|
||||
- Create a branch (e.g., `feature/task-041-tailwind-theme-hooks`).
|
||||
- Commit message suggestion: `chore: prep tailwind for theming`.
|
||||
- Include before/after screenshots only if an unexpected visual change occurs.
|
||||
42
tasks/done/042-style-token-scaffolding.md
Normal file
42
tasks/done/042-style-token-scaffolding.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Task 042 - Style Token Scaffolding
|
||||
|
||||
## Goal
|
||||
Create the shared token stylesheet placeholder and wire it into the app without defining actual variables yet.
|
||||
|
||||
## Prerequisites
|
||||
- Task 041 complete (Tailwind theme hooks ready).
|
||||
- Local dev server can be run (`npm run dev`).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] New file `src/styles/tokens.css` exists with section headings for light and dark palettes plus TODO comments for future tokens.
|
||||
- [ ] `src/index.css` imports `src/styles/tokens.css` near the top of the file.
|
||||
- [ ] No CSS variables are defined yet (only structure and comments).
|
||||
- [ ] App compiles and renders as before.
|
||||
|
||||
## Steps
|
||||
1. Create `src/styles/` if missing and add `tokens.css` with placeholders:
|
||||
```css
|
||||
:root {
|
||||
/* TODO: surface, text, accent, status tokens */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* TODO: dark-mode overrides */
|
||||
}
|
||||
```
|
||||
2. Import `./styles/tokens.css` from `src/index.css` after the Tailwind directives.
|
||||
3. Ensure no existing CSS variables are removed yet.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run dev` and confirm the renderer starts without warnings.
|
||||
- [ ] Visually spot-check a session view in light and dark mode for unchanged styling.
|
||||
|
||||
## Dependencies
|
||||
- Blocks Task 043 (color variable migration).
|
||||
|
||||
## Estimated Time
|
||||
0.25 hours
|
||||
|
||||
## Notes
|
||||
- Branch name suggestion: `feature/task-042-style-token-scaffolding`.
|
||||
- Keep the file ASCII-only and avoid trailing spaces.
|
||||
36
tasks/done/043-color-variable-migration.md
Normal file
36
tasks/done/043-color-variable-migration.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Task 043 - Color Variable Migration
|
||||
|
||||
## Goal
|
||||
Move all hard-coded color variables from `src/index.css` into `src/styles/tokens.css`, aligning with the documented light and dark palettes.
|
||||
|
||||
## Prerequisites
|
||||
- Task 042 complete (token scaffolding in place).
|
||||
- Access to color definitions from `docs/user-interface.md`.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Light mode color tokens (`--surface-*`, `--border-*`, `--text-*`, `--accent`, `--status-success|error|warning`) defined under `:root` in `src/styles/tokens.css`.
|
||||
- [ ] Dark mode overrides defined under `[data-theme="dark"]` in the same file.
|
||||
- [ ] `src/index.css` no longer declares color variables directly; it references the new tokens instead.
|
||||
- [ ] Theme toggle continues to switch palettes correctly.
|
||||
|
||||
## Steps
|
||||
1. Transfer existing color custom properties from `src/index.css` into `src/styles/tokens.css`, renaming them to semantic names that match the design doc.
|
||||
2. Add any missing variables required by the design spec (e.g., `--surface-muted`, `--text-inverted`).
|
||||
3. Update `src/index.css` to reference the new semantic variable names where necessary (e.g., `background-color: var(--surface-base)`).
|
||||
4. Remove redundant color declarations from `src/index.css` after confirming replacements.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run dev` and switch between light and dark themes.
|
||||
- [ ] Verify primary screens (instance tabs, session view, prompt input) in both themes for correct colors.
|
||||
- [ ] Confirm no CSS warnings/errors in the console.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 042.
|
||||
- Blocks Task 044 (typography tokens) and Task 045 (component migration batch 1).
|
||||
|
||||
## Estimated Time
|
||||
0.5 hours
|
||||
|
||||
## Notes
|
||||
- Align hex values with `docs/user-interface.md`; note any intentional deviations in the PR description.
|
||||
- Provide side-by-side screenshots (light/dark) in the PR for quicker review.
|
||||
34
tasks/done/044-typography-baseline.md
Normal file
34
tasks/done/044-typography-baseline.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 044 - Typography Baseline
|
||||
|
||||
## Goal
|
||||
Define the shared typography tokens and map them into Tailwind so text sizing stays consistent with the UI spec.
|
||||
|
||||
## Prerequisites
|
||||
- Task 043 complete (color variables migrated).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/styles/tokens.css` includes typography variables (font families, weights, line heights, size scale).
|
||||
- [ ] `tailwind.config.js` `theme.extend.fontFamily` and `theme.extend.fontSize` reference the new variables.
|
||||
- [ ] `src/index.css` applies body font and default text color using the new variables.
|
||||
- [ ] No existing components lose readability or spacing.
|
||||
|
||||
## Steps
|
||||
1. Add typography variables to `src/styles/tokens.css`, e.g., `--font-family-sans`, `--font-size-body`, `--line-height-body`.
|
||||
2. Extend Tailwind font families and sizes to match the variable names (`font-body`, `font-heading`, `text-body`, `text-label`).
|
||||
3. Update `src/index.css` body rules to use `var(--font-family-sans)` and the appropriate default sizes.
|
||||
4. Spot-check components for any stray font-size declarations that should use utilities instead.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run dev` and verify the app renders without layout shifts.
|
||||
- [ ] Inspect headings, labels, and body text to make sure sizes align with the design doc.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 043.
|
||||
- Blocks Task 045 (component migration batch 1).
|
||||
|
||||
## Estimated Time
|
||||
0.5 hours
|
||||
|
||||
## Notes
|
||||
- Keep variable names semantic; record any design clarifications in the Notes section of the PR.
|
||||
- Use browser dev tools to confirm computed font values match expectations (14px body, 16px headers, etc.).
|
||||
35
tasks/done/045-message-item-tailwind-refactor.md
Normal file
35
tasks/done/045-message-item-tailwind-refactor.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Task 045 - Message Item Tailwind Refactor
|
||||
|
||||
## Goal
|
||||
Refactor `MessageItem` to rely on Tailwind utilities and the new token variables instead of bespoke global CSS.
|
||||
|
||||
## Prerequisites
|
||||
- Task 043 complete (color tokens available).
|
||||
- Task 044 complete (typography baseline available).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/message-item.tsx` uses Tailwind utility classes (with CSS variable references where needed) for layout, colors, and typography.
|
||||
- [ ] Legacy `.message-item*` styles are removed from `src/index.css`.
|
||||
- [ ] Visual parity in light and dark modes is maintained for queued, sending, error, and generating states.
|
||||
|
||||
## Steps
|
||||
1. Replace `class="message-item ..."` and nested class usage with Tailwind class lists that reference tokens (e.g., `bg-[var(--surface-elevated)]`, `text-[var(--text-secondary)]`).
|
||||
2. Create any small reusable utility classes (e.g., `.chip`, `.card`) in a new `src/styles/components.css` if repeated patterns arise; keep them token-based.
|
||||
3. Delete the now-unused `.message-item` block from `src/index.css`.
|
||||
4. Verify conditional states (queued badge, sending indicator, error block) still render with correct colors/typography.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run dev` and load a session with mixed message states.
|
||||
- [ ] Toggle between light/dark themes to confirm token usage.
|
||||
- [ ] Use dev tools to ensure no stale `.message-item` selectors remain in the DOM.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Tasks 043 and 044.
|
||||
- Blocks future component refactor tasks (046+).
|
||||
|
||||
## Estimated Time
|
||||
0.75 hours
|
||||
|
||||
## Notes
|
||||
- Capture before/after screenshots (light + dark, streamed message) for review.
|
||||
- Mention any new utility classes in the PR description so reviewers know where to look.
|
||||
34
tasks/done/046-prompt-input-refactor.md
Normal file
34
tasks/done/046-prompt-input-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 046 - Prompt Input Tailwind Refactor
|
||||
|
||||
## Goal
|
||||
Port the prompt input stack to Tailwind utilities and shared tokens so it no longer depends on custom selectors in `src/index.css`.
|
||||
|
||||
## Prerequisites
|
||||
- Tasks 043-045 complete (color and typography tokens available, message item refactored).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/prompt-input.tsx` and nested elements use Tailwind + token classes for layout, borders, and typography.
|
||||
- [ ] Legacy selectors in `src/index.css` matching `.prompt-input-container`, `.prompt-input-wrapper`, `.prompt-input`, `.send-button`, `.prompt-input-hints`, `.hint`, `.hint kbd`, and related variants are removed or replaced with token-based utilities.
|
||||
- [ ] Input states (focus, disabled, multi-line expansion) and keyboard hint row look identical in light/dark modes.
|
||||
- [ ] Esc debounce handling and attachment hooks remain functional.
|
||||
|
||||
## Steps
|
||||
1. Audit existing markup in `prompt-input.tsx` and note the current class usage.
|
||||
2. Replace className strings with Tailwind utility stacks that reference CSS variables (e.g., `bg-[var(--surface-base)]`, `text-[var(--text-muted)]`).
|
||||
3. Introduce small reusable helpers (e.g., `.kbd` token utility) in `src/styles/components.css` if patterns recur elsewhere.
|
||||
4. Delete superseded CSS blocks from `src/index.css` once equivalents exist.
|
||||
5. Verify light/dark theme parity and interaction states manually.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, send a message with/without attachments, toggle disabled state, and confirm keyboard hints render correctly.
|
||||
|
||||
## Dependencies
|
||||
- Blocks future component refactors for the input stack.
|
||||
|
||||
## Estimated Time
|
||||
0.75 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-046-prompt-input-refactor`.
|
||||
- Capture light/dark screenshots for review if any subtle spacing changes occur.
|
||||
35
tasks/done/047-tabs-tailwind-refactor.md
Normal file
35
tasks/done/047-tabs-tailwind-refactor.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Task 047 - Tabs Tailwind Refactor
|
||||
|
||||
## Goal
|
||||
Refactor instance and session tab components to rely on Tailwind utilities and shared tokens, aligning with the design spec for spacing, typography, and state indicators.
|
||||
|
||||
## Prerequisites
|
||||
- Task 046 complete (prompt input refactor) to keep merges manageable.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/instance-tabs.tsx` and `src/components/session-tabs.tsx` no longer reference legacy `.instance-tabs`, `.session-tabs`, `.session-tab` classes from `src/index.css`.
|
||||
- [ ] Global CSS for tab bars (`.connection-status`, `.status-indicator`, `.status-dot`, `.session-view`) is replaced or minimized in favor of Tailwind utilities and token variables.
|
||||
- [ ] Active, hover, and error states match the UI spec in both themes, including badges/icons.
|
||||
- [ ] Tab bar layout remains responsive with overflow scrolling where applicable.
|
||||
|
||||
## Steps
|
||||
1. Catalogue existing tab-related classes used in both components and in `src/index.css`.
|
||||
2. Convert markup to Tailwind class lists, leveraging tokens for colors/borders (e.g., `bg-[var(--surface-secondary)]`).
|
||||
3. Add any reusable tab utilities to `src/styles/components.css` if needed.
|
||||
4. Remove obsolete CSS blocks from `src/index.css` once coverage is confirmed.
|
||||
5. Smoke-test tab interactions: switching, closing (where allowed), error state display, and overflow behavior.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, load multiple instances/sessions to verify active styling and horizontal scrolling.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 046 completion.
|
||||
- Blocks subsequent polish tasks for tab-level layout.
|
||||
|
||||
## Estimated Time
|
||||
1.0 hour
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-047-tabs-tailwind-refactor`.
|
||||
- Provide before/after screenshots (light/dark) of both tab bars in the PR for clarity.
|
||||
35
tasks/done/048-message-stream-refactor.md
Normal file
35
tasks/done/048-message-stream-refactor.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Task 048 - Message Stream & Tool Call Refactor
|
||||
|
||||
## Goal
|
||||
Finish migrating the message stream container, tool call blocks, and reasoning UI to Tailwind utilities and shared tokens.
|
||||
|
||||
## Prerequisites
|
||||
- Tasks 045-047 complete (message item, prompt input, and tabs refactored).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/message-stream.tsx`, `src/components/message-part.tsx`, and tool call subcomponents no longer depend on legacy classes (`.message-stream`, `.tool-call-message`, `.tool-call`, `.tool-call-header`, `.tool-call-preview`, `.tool-call-details`, `.reasoning-*`, `.scroll-to-bottom`, etc.).
|
||||
- [ ] Global CSS definitions for these selectors are removed from `src/index.css`, replaced by Tailwind utilities and token-aware helpers.
|
||||
- [ ] Scroll behavior (auto-scroll, “scroll to bottom” button) and collapsing/expanding tool calls behave as before in light/dark modes.
|
||||
- [ ] Markdown/code blocks continue to render properly within the new layout.
|
||||
|
||||
## Steps
|
||||
1. Inventory remaining global selectors in `src/index.css` associated with the stream/tool-call UI.
|
||||
2. Update component markup to use Tailwind classes, creating shared helpers in `src/styles/components.css` when patterns repeat.
|
||||
3. Remove or rewrite the corresponding CSS blocks in `src/index.css` to avoid duplication.
|
||||
4. Validate tool call states (pending/running/success/error), reasoning blocks, and markdown rendering visually.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, stream a message with tool calls and reasoning to ensure toggles and scroll helpers work.
|
||||
|
||||
## Dependencies
|
||||
- Depends on prompt input and tab refactors to reduce merge conflicts.
|
||||
- Unlocks subsequent layout cleanups for logs and empty states.
|
||||
|
||||
## Estimated Time
|
||||
1.25 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-048-message-stream-refactor`.
|
||||
- Capture short screen recording or screenshots if tool call layout adjustments were required.
|
||||
- Legacy `message-stream.tsx` has since been replaced by `message-stream-v2.tsx` using the normalized message store.
|
||||
33
tasks/done/049-unified-picker-refactor.md
Normal file
33
tasks/done/049-unified-picker-refactor.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Task 049 - Unified & File Picker Tailwind Refactor
|
||||
|
||||
## Goal
|
||||
Replace the hardcoded gray/blue class stacks in `UnifiedPicker` and `FilePicker` with token-based Tailwind utilities and shared dropdown helpers.
|
||||
|
||||
## Prerequisites
|
||||
- Tasks 041-048 complete (tokens, message components, tabs, prompt input refactored).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/unified-picker.tsx` and `src/components/file-picker.tsx` reference token-backed utility classes for surfaces, borders, typography, and states.
|
||||
- [ ] A shared dropdown utility block lives in `src/styles/components.css` (e.g., `.dropdown-surface`, `.dropdown-item`, `.dropdown-highlight`).
|
||||
- [ ] Legacy class strings using `bg-white`, `bg-gray-*`, `dark:bg-gray-*`, etc., are removed from both components.
|
||||
- [ ] Loading/empty states, highlights, and diff chips preserve their current behavior in light/dark themes.
|
||||
|
||||
## Steps
|
||||
1. Inventory all className usages in the two picker components.
|
||||
2. Add reusable dropdown utilities to `components.css`, powered by the existing tokens.
|
||||
3. Update component markup to use the new helpers and Tailwind utilities with `var(--token)` references for color.
|
||||
4. Smoke test: open the picker, filter results, confirm loading/empty states and diff counts.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, trigger the picker from prompt input (file mention) and ensure keyboard navigation/hover states look correct.
|
||||
|
||||
## Dependencies
|
||||
- Blocks further cleanup of selector components and modals.
|
||||
|
||||
## Estimated Time
|
||||
0.75 hours
|
||||
|
||||
## Notes
|
||||
- Branch name suggestion: `feature/task-049-unified-picker-refactor`.
|
||||
- Include before/after light & dark screenshots in the PR description if any visual tweaks occur.
|
||||
34
tasks/done/050-selector-popover-refactor.md
Normal file
34
tasks/done/050-selector-popover-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 050 - Selector Popover Tailwind Refactor
|
||||
|
||||
## Goal
|
||||
Bring `ModelSelector` and `OpencodeBinarySelector` popovers in line with the design tokens, eliminating manual light/dark class stacks.
|
||||
|
||||
## Prerequisites
|
||||
- Task 049 complete (dropdown utility helpers ready).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/model-selector.tsx` and `src/components/opencode-binary-selector.tsx` use token-backed utilities for surfaces, borders, focus rings, and typography.
|
||||
- [ ] Shared selector utilities live in `src/styles/components.css` (e.g., `.selector-trigger`, `.selector-option`, `.selector-section`).
|
||||
- [ ] All `dark:bg-gray-*` / `text-gray-*` combinations are removed in favor of tokens or newly added utilities.
|
||||
- [ ] Combobox states (highlighted, selected, disabled) and validation overlays preserve current UX.
|
||||
|
||||
## Steps
|
||||
1. Map all class usages in both selectors, noting duplicated patterns (trigger button, list items, badges).
|
||||
2. Create selector-specific helpers in `components.css` that rely on tokens.
|
||||
3. Update component markup to use the helpers and Tailwind utility additions.
|
||||
4. Verify validation/binary version chips and search input styling in both themes.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, open the selector popovers, search, and select options to confirm styling and focus rings.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 049 dropdown helpers.
|
||||
- Blocks folder selection advanced settings refactor.
|
||||
|
||||
## Estimated Time
|
||||
1.0 hour
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-050-selector-popover-refactor`.
|
||||
- Document any intentional color tweaks in the PR if tokens reveal contrast issues.
|
||||
34
tasks/done/051-command-palette-refactor.md
Normal file
34
tasks/done/051-command-palette-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 051 - Command Palette & Keyboard Hint Refactor
|
||||
|
||||
## Goal
|
||||
Align the command palette modal and keyboard hint UI with the shared token system, removing bespoke gray/black overlay styling.
|
||||
|
||||
## Prerequisites
|
||||
- Task 050 complete (selector helpers available for reuse).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/command-palette.tsx` uses token-backed utilities for overlay, surface, list items, and focus states.
|
||||
- [ ] `src/components/keyboard-hint.tsx` and any inline `<kbd>` styling leverage reusable helpers (`.kbd` etc.) from `components.css`.
|
||||
- [ ] Legacy utility combos in these components (`bg-gray-*`, `dark:bg-gray-*`, `text-gray-*`) are eliminated.
|
||||
- [ ] Palette overlay opacity, search field, section headers, and highlighted items match existing behavior in both themes.
|
||||
|
||||
## Steps
|
||||
1. Extract repeated modal/dropdown patterns into helpers (overlay, surface, list item) if not already present.
|
||||
2. Update command palette markup to use the helpers and token-aware Tailwind classes.
|
||||
3. Refactor `keyboard-hint.tsx` to rely on shared `.kbd` styling and tokens.
|
||||
4. Verify keyboard navigation, highlighted items, and section headers visually.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] In dev mode, open the command palette, search, navigate with arrow keys, and confirm highlight/focus styling.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 050.
|
||||
- Blocks folder selection advanced settings refactor (which reuses keyboard hints).
|
||||
|
||||
## Estimated Time
|
||||
0.75 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-051-command-palette-refactor`.
|
||||
- Include GIF/screenshots if overlay opacity or highlight timing needed adjustment.
|
||||
34
tasks/done/052-folder-info-panels-refactor.md
Normal file
34
tasks/done/052-folder-info-panels-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 052 - Folder Selection & Info Panels Refactor
|
||||
|
||||
## Goal
|
||||
Migrate the folder selection view, info view, and logs view to token-driven utilities, removing bespoke gray styling blocks.
|
||||
|
||||
## Prerequisites
|
||||
- Task 051 complete (modal/kbd helpers ready).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/folder-selection-view.tsx`, `src/components/info-view.tsx`, and `src/components/logs-view.tsx` use token-backed utilities or shared helpers from `components.css`.
|
||||
- [ ] Panel surfaces, headers, section dividers, and scroll containers reference tokens rather than raw Tailwind color values.
|
||||
- [ ] `.session-view` global rule in `src/index.css` is replaced with a utility/helper equivalent.
|
||||
- [ ] Loading/empty states and action buttons keep their existing behavior and contrast in both themes.
|
||||
|
||||
## Steps
|
||||
1. Catalog remaining raw color classes in the three components.
|
||||
2. Add reusable panel helpers (e.g., `.panel`, `.panel-header`, `.panel-body`) to `components.css` if helpful.
|
||||
3. Update component markup to use helpers and token-aware Tailwind classes.
|
||||
4. Remove residual `bg-gray-*` / `text-gray-*` from these components and clean up `index.css`.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Manual spot check: recent folders list, info view logs, logs view streaming; confirm hover states and CTAs.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 051.
|
||||
- Blocks final markdown/global CSS cleanup.
|
||||
|
||||
## Estimated Time
|
||||
1.25 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-052-folder-info-panels-refactor`.
|
||||
- Capture screenshots (light/dark) of folder selection and logs panels for review.
|
||||
34
tasks/done/053-markdown-style-refactor.md
Normal file
34
tasks/done/053-markdown-style-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 053 - Markdown & Code Block Styling Refactor
|
||||
|
||||
## Goal
|
||||
Extract the remaining markdown/code-block styling from `src/index.css` into token-aware utilities and ensure all prose rendering uses the shared system.
|
||||
|
||||
## Prerequisites
|
||||
- Task 052 complete (panels cleaned up).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/index.css` no longer contains `.prose`, `.markdown-code-block`, `.code-block-header`, `.code-block-copy`, or `.code-block-inline` blocks; equivalent styling lives in a new `src/styles/markdown.css` (imported from `index.css`) and/or token helpers.
|
||||
- [ ] New markdown helpers rely on tokens for colors, borders, and typography (no hard-coded hex values).
|
||||
- [ ] Code block copy button, language label, and inline code maintain current interaction and contrast in both themes.
|
||||
- [ ] `MessagePart` markdown rendering (`src/components/markdown.tsx`) automatically picks up the new styling without component changes.
|
||||
|
||||
## Steps
|
||||
1. Move markdown-related CSS into a dedicated `styles/markdown.css` file, rewriting colors with tokens.
|
||||
2. Replace any legacy values (e.g., `text-gray-700`) with token references.
|
||||
3. Update `src/index.css` to import the new stylesheet after tokens/components layers.
|
||||
4. Verify formatted markdown in the message stream (headings, lists, code blocks, copy button).
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Manually view messages with markdown (headings, inline code, block code, tables) in both themes.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 052.
|
||||
- Blocks final cleanup task for attachment/keyboard chips.
|
||||
|
||||
## Estimated Time
|
||||
0.75 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-053-markdown-style-refactor`.
|
||||
- If additional tokens are needed (e.g., `--surface-prose`), document them in the PR.
|
||||
34
tasks/done/054-attachment-chip-refactor.md
Normal file
34
tasks/done/054-attachment-chip-refactor.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Task 054 - Attachment & Misc Chip Refactor
|
||||
|
||||
## Goal
|
||||
Standardize attachment chips and any remaining inline badge styles to use the shared token helpers.
|
||||
|
||||
## Prerequisites
|
||||
- Task 053 complete (markdown styling moved).
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `src/components/attachment-chip.tsx` uses the `.attachment-chip` and `.attachment-remove` helpers (or equivalent token-backed utilities) instead of hardcoded Tailwind color stacks.
|
||||
- [ ] Any other chip/badge helpers introduced in earlier tasks reference the same token palette (audit `folder-selection-view.tsx`, `unified-picker.tsx`, etc.).
|
||||
- [ ] No component contains inline `bg-blue-*` / `dark:bg-blue-*` combinations after the refactor.
|
||||
- [ ] Interaction states (hover, focus) remain consistent in both themes.
|
||||
|
||||
## Steps
|
||||
1. Update `attachment-chip.tsx` to import and use the shared helper classes.
|
||||
2. Search the codebase for remaining `bg-blue-`, `bg-gray-900`, `dark:bg-blue-` patterns; convert them to tokenized utilities or helpers.
|
||||
3. Adjust `components.css` helpers if needed (e.g., expose variations for neutral vs accent chips).
|
||||
4. Verify attachments display correctly in the prompt input and message list.
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Run `npm run build`.
|
||||
- [ ] Manually add/remove attachments via the prompt input, confirming chip styling survives theme toggle.
|
||||
|
||||
## Dependencies
|
||||
- Depends on Task 053.
|
||||
- Finalizes legacy styling removal.
|
||||
|
||||
## Estimated Time
|
||||
0.5 hours
|
||||
|
||||
## Notes
|
||||
- Branch suggestion: `feature/task-054-attachment-chip-refactor`.
|
||||
- Document any new helper names in the task PR for traceability.
|
||||
37
tasks/todo/023-symbol-attachments.md
Normal file
37
tasks/todo/023-symbol-attachments.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Symbol Attachments
|
||||
description: Attach code symbols with LSP integration.
|
||||
---
|
||||
|
||||
Implement Symbol Attachments
|
||||
|
||||
---
|
||||
|
||||
### LSP Integration
|
||||
|
||||
Integrate with the Language Server Protocol (LSP) to get a list of symbols in the current project.
|
||||
|
||||
---
|
||||
|
||||
### @ Symbol Autocomplete
|
||||
|
||||
When a user types `@` followed by a symbol-like pattern, trigger an autocomplete with relevant code symbols.
|
||||
|
||||
Include symbols from various file types supported by LSP.
|
||||
|
||||
---
|
||||
|
||||
### Attach and Navigate Symbols
|
||||
|
||||
Allow users to select a symbol from the autocomplete list to attach it to the prompt.
|
||||
|
||||
Display attached symbols as interactive chips. Optionally, implement functionality to jump to the symbol definition in an editor.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Typing `@` followed by a partial symbol name displays matching symbol suggestions.
|
||||
- Selecting a symbol creates an attachment chip.
|
||||
- Attached symbols are correctly formatted for submission.
|
||||
- (Optional) Clicking a symbol chip navigates to its definition.
|
||||
Reference in New Issue
Block a user