mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
38 Commits
storage-te
...
v0.1.41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d69e2006 | ||
|
|
4b7c623592 | ||
|
|
b91884bc54 | ||
|
|
963c79265a | ||
|
|
ade1e338ea | ||
|
|
c13972c835 | ||
|
|
d8d04c545e | ||
|
|
613450bac8 | ||
|
|
cafff08a30 | ||
|
|
cb60f2a596 | ||
|
|
58d72bea87 | ||
|
|
953f7898e7 | ||
|
|
fd06e109be | ||
|
|
ba1bb1646e | ||
|
|
3ee11efcd0 | ||
|
|
1930a8a2f2 | ||
|
|
f7fd41a5f8 | ||
|
|
3af5b0f031 | ||
|
|
cce8dee21c | ||
|
|
2d02db6ae0 | ||
|
|
66732a2f48 | ||
|
|
30ebd4b777 | ||
|
|
e95554d782 | ||
|
|
5cf8b7549d | ||
|
|
9c4dee5364 | ||
|
|
c635eabff3 | ||
|
|
51411182fe | ||
|
|
f53c770a71 | ||
|
|
8d55764313 | ||
|
|
4b12ebd5c0 | ||
|
|
27c8cfbd4b | ||
|
|
07b077f1a2 | ||
|
|
ee50d3b8d0 | ||
|
|
4b93f990d2 | ||
|
|
87f297e755 | ||
|
|
b50b065bcc | ||
|
|
4e8e2589bf | ||
|
|
8eddb2ee18 |
48
.github/workflows/android-apk-release.yml
vendored
48
.github/workflows/android-apk-release.yml
vendored
@@ -34,15 +34,33 @@ jobs:
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[2]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--generate-notes
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -107,24 +125,6 @@ jobs:
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for GitHub release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 90); do
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Found release for tag $RELEASE_TAG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||
sleep 20
|
||||
done
|
||||
|
||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||
exit 1
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/deploy-app.yml
vendored
2
.github/workflows/deploy-app.yml
vendored
@@ -4,7 +4,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-rc.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/deploy-website.yml
vendored
1
.github/workflows/deploy-website.yml
vendored
@@ -15,6 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
210
.github/workflows/desktop-release.yml
vendored
210
.github/workflows/desktop-release.yml
vendored
@@ -58,24 +58,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -110,24 +93,6 @@ jobs:
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -149,6 +114,105 @@ jobs:
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-mac-manifest:
|
||||
needs: [publish-macos]
|
||||
if: ${{ needs.publish-macos.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: mac-manifest-*
|
||||
|
||||
- name: Merge manifests
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
// Simple YAML parser for electron-builder's latest-mac.yml format
|
||||
function parseManifest(text) {
|
||||
const lines = text.split('\n');
|
||||
const result = { files: [] };
|
||||
let currentFile = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
||||
else if (line.trim().startsWith('- url:')) {
|
||||
currentFile = { url: line.trim().replace('- url: ', '') };
|
||||
result.files.push(currentFile);
|
||||
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
||||
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
||||
} else if (line.trim().startsWith('size:') && currentFile) {
|
||||
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
||||
currentFile = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toYaml(manifest) {
|
||||
let out = `version: ${manifest.version}\n`;
|
||||
out += `files:\n`;
|
||||
for (const f of manifest.files) {
|
||||
out += ` - url: ${f.url}\n`;
|
||||
out += ` sha512: ${f.sha512}\n`;
|
||||
out += ` size: ${f.size}\n`;
|
||||
}
|
||||
out += `path: ${manifest.path}\n`;
|
||||
out += `sha512: ${manifest.sha512}\n`;
|
||||
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
||||
return out;
|
||||
}
|
||||
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
|
||||
// Merge: all files from both, default path points to arm64 zip
|
||||
const merged = {
|
||||
version: arm64.version,
|
||||
files: [...arm64.files, ...x64.files],
|
||||
path: arm64.path,
|
||||
sha512: arm64.sha512,
|
||||
releaseDate: arm64.releaseDate || x64.releaseDate,
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
|
||||
- name: Upload merged manifest to release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
permissions:
|
||||
@@ -164,24 +228,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -216,24 +263,6 @@ jobs:
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -265,24 +294,7 @@ jobs:
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
version="${release_tag#v}"
|
||||
echo "DESKTOP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -325,24 +337,6 @@ jobs:
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Detect existing GitHub release state
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if release_draft="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
|
||||
if [[ "$release_draft" == "true" ]]; then
|
||||
release_type="draft"
|
||||
else
|
||||
release_type="release"
|
||||
fi
|
||||
else
|
||||
release_type="draft"
|
||||
fi
|
||||
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
10
.github/workflows/release-notes-sync.yml
vendored
10
.github/workflows/release-notes-sync.yml
vendored
@@ -19,11 +19,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
draft:
|
||||
description: "Create missing release as draft."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
@@ -48,7 +43,6 @@ jobs:
|
||||
REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||
INPUT_DRAFT: ${{ github.event.inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -70,8 +64,4 @@ jobs:
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
if [ "${INPUT_DRAFT:-false}" = "true" ]; then
|
||||
args+=(--draft)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,5 +1,48 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.41 - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
||||
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
||||
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
||||
- Fixed context menu for copy/paste across the desktop app.
|
||||
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
||||
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||
|
||||
### Improved
|
||||
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||
|
||||
## 0.1.40 - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Workspace tabs can now be closed in batches.
|
||||
|
||||
### Improved
|
||||
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||
|
||||
### Fixed
|
||||
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||
- File diff tracking in workspaces now works correctly on Linux.
|
||||
- iPad layout now renders correctly in desktop mode.
|
||||
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
||||
|
||||
## 0.1.39 - 2026-03-30
|
||||
|
||||
### Added
|
||||
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
||||
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
||||
|
||||
### Fixed
|
||||
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
||||
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
||||
|
||||
## 0.1.38 - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -35,8 +35,6 @@ npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
npm run db:query # Show DB table row counts
|
||||
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
@@ -46,11 +46,13 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
## Cloud build + submit (EAS)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger:
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -35,25 +35,6 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the SQLite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
```bash
|
||||
@@ -12,6 +19,8 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
@@ -21,27 +30,44 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Draft release flow
|
||||
## Release candidate flow
|
||||
|
||||
```bash
|
||||
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
|
||||
# ... test builds from the draft release assets ...
|
||||
npm run release:finalize # Publish npm, promote draft to published
|
||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- `draft-release:patch` creates the GitHub Release as a draft so desktop assets, APK uploads, and synced notes attach to it
|
||||
- `release:finalize` publishes npm and promotes the same draft release
|
||||
- Use the same semver tag for both; don't cut a second tag
|
||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
|
||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||
|
||||
Use the RC path when you need to:
|
||||
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
```bash
|
||||
# Desktop (all platforms)
|
||||
@@ -54,21 +80,29 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
||||
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
|
||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
|
||||
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
|
||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
@@ -76,11 +110,23 @@ The website depends on the changelog to determine the latest download version. T
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
# Storage Revamp Plan
|
||||
|
||||
Status: active rollout, phases 1 and 2 complete
|
||||
|
||||
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
|
||||
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
|
||||
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
|
||||
|
||||
## Goals
|
||||
|
||||
- make structured records durable in Drizzle + SQLite
|
||||
- make projects and workspaces explicit first-class records
|
||||
- stop deriving project/workspace identity from agent `cwd`
|
||||
- keep agent snapshot persistence behind clear ownership
|
||||
- move committed timeline history to storage-owned rows
|
||||
- remove legacy JSON and in-memory authority once the DB path is proven
|
||||
|
||||
## Out of scope
|
||||
|
||||
- moving config, keypairs, push tokens, or server identity into the DB
|
||||
- persisting raw provider deltas or transport-only chunk streams
|
||||
- designing a hosted/remote database story beyond keeping the schema portable
|
||||
- durable reasoning history unless product explicitly asks for it later
|
||||
|
||||
## Current state
|
||||
|
||||
The storage revamp is no longer hypothetical.
|
||||
|
||||
Completed:
|
||||
|
||||
- Drizzle + SQLite database bootstrap is in place
|
||||
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
|
||||
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
|
||||
- `agent_snapshots.workspace_id` is `NOT NULL`
|
||||
- legacy JSON import feeds the DB-backed structured records
|
||||
- project/workspace records use explicit `directory` fields instead of path-as-identity
|
||||
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
|
||||
- `workspace-reconciliation-service.ts` is deleted
|
||||
- `workspace-registry-bootstrap.ts` is deleted
|
||||
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
|
||||
|
||||
Still pending:
|
||||
|
||||
- explicit `create_project` / `create_workspace` API cleanup
|
||||
- final archive cascade behavior for descendants and live agents
|
||||
- committed timeline storage cutover
|
||||
- removal of remaining legacy JSON and in-memory committed-history authority
|
||||
|
||||
## Converged decisions
|
||||
|
||||
### Structured record authority
|
||||
|
||||
Projects, workspaces, and agent snapshots are DB-backed structured records.
|
||||
The server should not recreate project/workspace identity from:
|
||||
|
||||
- git remotes
|
||||
- worktree main-repo roots
|
||||
- normalized cwd strings
|
||||
|
||||
Temporary exception:
|
||||
|
||||
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
|
||||
`workspaceId` explicitly
|
||||
|
||||
That fallback is transitional and should be deleted once the client always sends the workspace id.
|
||||
|
||||
### Storage seams
|
||||
|
||||
The useful seams remain concrete and domain-shaped:
|
||||
|
||||
- `ProjectRegistry`
|
||||
- `WorkspaceRegistry`
|
||||
- `AgentSnapshotStore`
|
||||
- `AgentTimelineStore`
|
||||
|
||||
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
|
||||
|
||||
### Timeline contract
|
||||
|
||||
The long-term timeline contract remains:
|
||||
|
||||
- committed rows are durable, canonical history
|
||||
- provisional live updates are transient subscription state
|
||||
- committed history is fetched by seq
|
||||
- provider history replay is not the durability mechanism
|
||||
|
||||
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
|
||||
stable DB-backed agent and workspace identity.
|
||||
|
||||
## Remaining phases
|
||||
|
||||
### Phase 3: Explicit creation and archive cleanup
|
||||
|
||||
Goal:
|
||||
Remove the last transitional write paths that still infer state from directories.
|
||||
|
||||
Required work:
|
||||
|
||||
- add explicit `create_project` handling
|
||||
- add explicit `create_workspace` handling
|
||||
- make agent creation require `workspaceId` once the UI is ready
|
||||
- finish archive semantics for workspaces/projects and any descendant agent state
|
||||
- remove the temporary find-or-create-by-directory fallback from agent creation
|
||||
|
||||
Exit gate:
|
||||
|
||||
- project/workspace creation is explicit end to end
|
||||
- no normal creation path infers identity from cwd or git metadata
|
||||
- archive flows behave consistently for structured records and live runtime state
|
||||
|
||||
### Phase 4: Timeline storage cutover
|
||||
|
||||
Goal:
|
||||
Make committed history durable and storage-owned.
|
||||
|
||||
Required work:
|
||||
|
||||
- make `AgentTimelineStore` authoritative for committed history
|
||||
- write one committed row per finalized logical item
|
||||
- support tail, before-seq, and after-seq queries from storage
|
||||
- stop treating provider history hydration as the normal refresh/load path
|
||||
- keep provisional live updates in memory only
|
||||
|
||||
Exit gate:
|
||||
|
||||
- committed history survives daemon restart
|
||||
- reconnect uses committed catch-up plus future live events without gaps or duplicates
|
||||
- unloaded agents can serve committed history from storage alone
|
||||
|
||||
### Phase 5: Legacy cleanup
|
||||
|
||||
Goal:
|
||||
Remove compatibility paths after the DB-backed model is fully authoritative.
|
||||
|
||||
Required work:
|
||||
|
||||
- remove legacy JSON authority for structured records
|
||||
- remove in-memory committed-history ownership
|
||||
- remove provider-history rehydrate compatibility paths
|
||||
- trim dead protocol and reducer logic from the pre-storage model
|
||||
- update architecture docs to match the final model
|
||||
|
||||
Exit gate:
|
||||
|
||||
- there is one durable storage path for structured records
|
||||
- there is one durable storage path for committed timeline history
|
||||
- the runtime no longer depends on the removed JSON/in-memory model
|
||||
|
||||
## Data model summary
|
||||
|
||||
### Projects
|
||||
|
||||
- integer primary key
|
||||
- `directory` is unique
|
||||
- `display_name`
|
||||
- `kind`: `git | directory`
|
||||
- optional `git_remote`
|
||||
- timestamps and archive state
|
||||
|
||||
### Workspaces
|
||||
|
||||
- integer primary key
|
||||
- belongs to a project by `project_id`
|
||||
- `directory` is unique
|
||||
- `display_name`
|
||||
- `kind`: `checkout | worktree`
|
||||
- timestamps and archive state
|
||||
|
||||
### Agent snapshots
|
||||
|
||||
- `agent_id` remains the primary key
|
||||
- belongs to a workspace by integer `workspace_id`
|
||||
- `workspace_id` is required
|
||||
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
|
||||
|
||||
### Timeline rows
|
||||
|
||||
Target shape once Phase 4 lands:
|
||||
|
||||
- `agent_id`
|
||||
- committed `seq`
|
||||
- committed timestamp
|
||||
- canonical finalized item payload
|
||||
|
||||
Not part of durable history:
|
||||
|
||||
- raw streaming chunks
|
||||
- provisional assistant text
|
||||
- provisional reasoning text
|
||||
|
||||
## Verification requirements
|
||||
|
||||
Every remaining phase should keep the same bar:
|
||||
|
||||
- `npm run typecheck`
|
||||
- targeted tests for the touched storage/session/runtime paths
|
||||
- migration/import coverage when storage authority changes
|
||||
- reconnect and catch-up scenario coverage when timeline behavior changes
|
||||
|
||||
At minimum, timeline cutover must explicitly prove:
|
||||
|
||||
- `fetch-after-seq`
|
||||
- `fetch-before-seq`
|
||||
- restart durability
|
||||
- no-gap/no-duplicate reconnect behavior
|
||||
|
||||
## Main risks
|
||||
|
||||
- timeline work reintroduces provider-history replay as hidden authority
|
||||
- archive behavior diverges between stored records and live in-memory agents
|
||||
- explicit creation work leaves the transitional cwd fallback in place too long
|
||||
- cleanup stalls after compatibility paths stop being exercised
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
|
||||
it is probably moving in the wrong direction.
|
||||
@@ -1,506 +0,0 @@
|
||||
# Terminal Mode — Implementation Plan
|
||||
|
||||
## Concept
|
||||
|
||||
Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI.
|
||||
|
||||
**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView.
|
||||
|
||||
## What Changes
|
||||
|
||||
### Phase 1: Server — Data Model & Provider Interface
|
||||
|
||||
#### 1.1 Add `terminal` flag to `ManagedAgentBase`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||
|
||||
```typescript
|
||||
type ManagedAgentBase = {
|
||||
// ...existing fields...
|
||||
terminal: boolean; // NEW — if true, this agent renders as a terminal TUI
|
||||
};
|
||||
```
|
||||
|
||||
This flag is set at creation time and never changes. A terminal agent is always a terminal agent.
|
||||
|
||||
#### 1.2 Add `terminal` to `AgentSessionConfig`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type AgentSessionConfig = {
|
||||
// ...existing fields...
|
||||
terminal?: boolean; // NEW — create as terminal agent
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.3 Add `terminal` to the Zod schema
|
||||
|
||||
**File:** `packages/server/src/shared/messages.ts`
|
||||
|
||||
Add to `AgentSessionConfigSchema`:
|
||||
```typescript
|
||||
terminal: z.boolean().optional(),
|
||||
```
|
||||
|
||||
Add to the `AgentStateSchema` (the wire format sent to clients):
|
||||
```typescript
|
||||
terminal: z.boolean().optional(),
|
||||
```
|
||||
|
||||
#### 1.4 Add terminal command builders to `AgentClient`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type TerminalCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface AgentClient {
|
||||
// ...existing methods...
|
||||
|
||||
/**
|
||||
* Build the shell command to launch this agent's TUI for a new session.
|
||||
* Only available if capabilities.supportsTerminalMode is true.
|
||||
*/
|
||||
buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand;
|
||||
|
||||
/**
|
||||
* Build the shell command to resume an existing session in the agent's TUI.
|
||||
* Only available if capabilities.supportsTerminalMode is true.
|
||||
*/
|
||||
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 Add `supportsTerminalMode` capability
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type AgentCapabilityFlags = {
|
||||
// ...existing flags...
|
||||
supportsTerminalMode: boolean; // NEW
|
||||
};
|
||||
```
|
||||
|
||||
Also add to the Zod schema in `messages.ts`:
|
||||
```typescript
|
||||
supportsTerminalMode: z.boolean(),
|
||||
```
|
||||
|
||||
#### 1.6 Implement terminal command builders in providers
|
||||
|
||||
**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
const args: string[] = [];
|
||||
if (config.modeId === "bypassPermissions") {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
}
|
||||
if (config.model) args.push("--model", config.model);
|
||||
// mode mapping: default → nothing, plan → --plan, etc.
|
||||
return { command: "claude", args, env: {} };
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
return {
|
||||
command: "claude",
|
||||
args: ["--resume", handle.sessionId],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
const args: string[] = [];
|
||||
if (config.model) args.push("--model", config.model);
|
||||
if (config.modeId) args.push("--approval-mode", config.modeId);
|
||||
return { command: "codex", args, env: {} };
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
return {
|
||||
command: "codex",
|
||||
args: ["--resume", handle.nativeHandle ?? handle.sessionId],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
return { command: "opencode", args: [], env: {} };
|
||||
}
|
||||
// No resume support for OpenCode initially
|
||||
```
|
||||
|
||||
Capabilities for each provider:
|
||||
- Claude: `supportsTerminalMode: true`
|
||||
- Codex: `supportsTerminalMode: true`
|
||||
- OpenCode: `supportsTerminalMode: true`
|
||||
|
||||
#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||
|
||||
When `config.terminal === true`:
|
||||
1. Do NOT call `client.createSession()` — there is no managed session
|
||||
2. Call `client.buildTerminalCreateCommand(config)` to get the command
|
||||
3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command
|
||||
4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null`
|
||||
5. Store the terminal ID in the agent's metadata or a new field
|
||||
6. The agent's persistence handle can be populated later (the CLI will create its own session file)
|
||||
|
||||
```typescript
|
||||
async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record<string, string> }): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
|
||||
if (normalizedConfig.terminal) {
|
||||
// Terminal mode — no managed session, just build the command
|
||||
const buildCmd = client.buildTerminalCreateCommand;
|
||||
if (!buildCmd) {
|
||||
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||
}
|
||||
const cmd = buildCmd.call(client, normalizedConfig);
|
||||
return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, {
|
||||
labels: options?.labels,
|
||||
});
|
||||
}
|
||||
|
||||
// ...existing managed agent flow...
|
||||
}
|
||||
```
|
||||
|
||||
New method `registerTerminalAgent()`:
|
||||
- Creates a ManagedAgent with `terminal: true`
|
||||
- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect)
|
||||
- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state)
|
||||
- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents)
|
||||
- Broadcasts `agent_state` event so clients know about it
|
||||
|
||||
#### 1.8 New message: create terminal for agent
|
||||
|
||||
The client needs a way to request a terminal for a terminal agent. Options:
|
||||
|
||||
**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command.
|
||||
|
||||
**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step.
|
||||
|
||||
**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided:
|
||||
- Look up the agent (must be a terminal agent)
|
||||
- Use the agent's stored command to create the terminal
|
||||
- Associate the terminal with the agent
|
||||
|
||||
**File:** `packages/server/src/shared/messages.ts`
|
||||
|
||||
```typescript
|
||||
const CreateTerminalRequestMessageSchema = z.object({
|
||||
type: z.literal("create_terminal_request"),
|
||||
cwd: z.string(),
|
||||
name: z.string().optional(),
|
||||
agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent
|
||||
requestId: z.string(),
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.9 Terminal → Agent lifecycle binding
|
||||
|
||||
When a terminal associated with a terminal agent exits:
|
||||
- Set agent lifecycle to `"closed"`
|
||||
- Attempt to detect the agent's session file for persistence handle
|
||||
- Broadcast state update
|
||||
|
||||
When a terminal agent is opened from the sessions page:
|
||||
- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists
|
||||
- Otherwise calls `buildTerminalCreateCommand(config)`
|
||||
- Creates a new terminal with that command
|
||||
|
||||
#### 1.10 Extend `createTerminal()` to support command + args
|
||||
|
||||
**File:** `packages/server/src/terminal/terminal.ts`
|
||||
|
||||
```typescript
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
name?: string;
|
||||
command?: string; // NEW — if provided, run this instead of shell
|
||||
args?: string[]; // NEW — arguments for command
|
||||
}
|
||||
```
|
||||
|
||||
In `createTerminal()`:
|
||||
```typescript
|
||||
const spawnCommand = options.command ?? shell;
|
||||
const spawnArgs = options.command ? (options.args ?? []) : [];
|
||||
|
||||
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||
name: "xterm-256color",
|
||||
cols, rows, cwd,
|
||||
env: { ...process.env, ...env, TERM: "xterm-256color" },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: App — Draft UI & Terminal Toggle
|
||||
|
||||
#### 2.1 Add terminal toggle to draft tab
|
||||
|
||||
**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx`
|
||||
|
||||
Add a toggle switch in the draft UI: **"Chat" / "Terminal"**
|
||||
|
||||
State:
|
||||
```typescript
|
||||
const [isTerminalMode, setIsTerminalMode] = useState(false);
|
||||
```
|
||||
|
||||
The toggle should be persistent per draft (stored in the draft store or as a preference).
|
||||
|
||||
When terminal mode is selected:
|
||||
- The provider/model pickers still work (same UI)
|
||||
- The mode picker still works
|
||||
- The "send" button label changes to "Launch" or "Start"
|
||||
- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI)
|
||||
|
||||
#### 2.2 Modify agent creation to pass `terminal: true`
|
||||
|
||||
When the user submits a draft in terminal mode:
|
||||
|
||||
```typescript
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
model: selectedModel,
|
||||
modeId: selectedMode,
|
||||
terminal: true, // NEW
|
||||
};
|
||||
```
|
||||
|
||||
The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed.
|
||||
|
||||
#### 2.3 Terminal mode in `AgentStatusBar`
|
||||
|
||||
**File:** `packages/app/src/components/agent-status-bar.tsx`
|
||||
|
||||
When rendering a draft's status bar, filter the capability:
|
||||
- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected
|
||||
- The terminal toggle can live next to the provider selector or as a segmented control above the input area
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: App — Agent Panel Rendering
|
||||
|
||||
#### 3.1 Branch rendering in `AgentPanel`
|
||||
|
||||
**File:** `packages/app/src/panels/agent-panel.tsx`
|
||||
|
||||
```typescript
|
||||
function AgentPanelContent({ agentId, ... }) {
|
||||
const agent = useAgentState(agentId);
|
||||
|
||||
if (agent?.terminal) {
|
||||
return <TerminalAgentPanel agentId={agentId} agent={agent} />;
|
||||
}
|
||||
|
||||
return <AgentPanelBody agent={agent} ... />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 New component: `TerminalAgentPanel`
|
||||
|
||||
**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file)
|
||||
|
||||
This component:
|
||||
1. Gets the terminal ID associated with the agent (from agent metadata or a new field)
|
||||
2. Renders a `TerminalPane` connected to that terminal session
|
||||
3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })`
|
||||
4. Handles terminal exit → agent close lifecycle
|
||||
|
||||
Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal.
|
||||
|
||||
#### 3.3 Tab descriptor for terminal agents
|
||||
|
||||
**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor`
|
||||
|
||||
The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge.
|
||||
|
||||
Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Sessions Page
|
||||
|
||||
#### 4.1 Terminal agents appear in sessions list
|
||||
|
||||
No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`.
|
||||
|
||||
#### 4.2 Opening a terminal agent from sessions
|
||||
|
||||
**File:** `packages/app/src/screens/sessions/` (sessions screen)
|
||||
|
||||
When the user clicks a closed terminal agent:
|
||||
1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists
|
||||
2. Creates a new terminal with that command
|
||||
3. Opens agent tab in workspace
|
||||
|
||||
If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: CLI Gating
|
||||
|
||||
#### 5.1 `paseo send` — error for terminal agents
|
||||
|
||||
**File:** `packages/cli/src/commands/send.ts`
|
||||
|
||||
```typescript
|
||||
if (agent.terminal) {
|
||||
throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead.");
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 `paseo run` — could support `--terminal` flag (future)
|
||||
|
||||
Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only.
|
||||
|
||||
#### 5.3 `paseo ls` — show terminal flag
|
||||
|
||||
Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents.
|
||||
|
||||
---
|
||||
|
||||
## Wire Format Changes Summary
|
||||
|
||||
### AgentSessionConfig (create request)
|
||||
```diff
|
||||
{
|
||||
provider: string;
|
||||
cwd: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
+ terminal?: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### AgentState (server → client)
|
||||
```diff
|
||||
{
|
||||
id: string;
|
||||
provider: string;
|
||||
lifecycle: string;
|
||||
+ terminal?: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### AgentCapabilityFlags
|
||||
```diff
|
||||
{
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
+ supportsTerminalMode: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CreateTerminalRequest
|
||||
```diff
|
||||
{
|
||||
type: "create_terminal_request";
|
||||
cwd: string;
|
||||
name?: string;
|
||||
+ agentId?: string;
|
||||
requestId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### TerminalCommand (new type)
|
||||
```typescript
|
||||
{
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases & Agent Assignments
|
||||
|
||||
### Phase 1: Server data model (1 agent)
|
||||
- Add `terminal` to types, schemas, and agent manager
|
||||
- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient`
|
||||
- Add `supportsTerminalMode` capability flag
|
||||
- Extend `createTerminal()` to support command+args
|
||||
- Implement terminal agent creation flow in `AgentManager`
|
||||
- Wire terminal exit → agent close lifecycle
|
||||
- Implement command builders in Claude, Codex, OpenCode providers
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 2: App draft UI + terminal toggle (1 agent)
|
||||
- Add terminal mode toggle to `workspace-draft-agent-tab.tsx`
|
||||
- Pass `terminal: true` in config when toggle is on
|
||||
- Filter toggle based on `supportsTerminalMode` capability
|
||||
- Persist toggle preference
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 3: App panel rendering (1 agent)
|
||||
- Branch `AgentPanelContent` on `agent.terminal`
|
||||
- Create `TerminalAgentPanel` component
|
||||
- Handle terminal creation for agent on open
|
||||
- Handle terminal exit lifecycle
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 4: Sessions page + CLI gating (1 agent)
|
||||
- Terminal agents show in sessions with badge
|
||||
- Opening from sessions resumes or creates terminal
|
||||
- `paseo send` errors for terminal agents
|
||||
- `paseo ls` shows terminal badge
|
||||
- Typecheck must pass
|
||||
|
||||
---
|
||||
|
||||
## Feature Interaction Guards
|
||||
|
||||
Terminal agents are explicitly excluded from automated dispatch paths:
|
||||
|
||||
- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false`
|
||||
- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false`
|
||||
- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send
|
||||
- **CLI `paseo send`**: Returns error for terminal agents
|
||||
- **MCP agent creation**: Programmatic paths don't pass `terminal: true`
|
||||
|
||||
All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents.
|
||||
|
||||
## What This Does NOT Change
|
||||
|
||||
- The existing managed agent flow is untouched
|
||||
- Terminal sessions (non-agent) still work as before
|
||||
- The `AgentSession` interface is unchanged
|
||||
- Mobile experience is unchanged (terminal mode is web/desktop only for now)
|
||||
- No new providers are added (existing providers gain terminal command builders)
|
||||
- No hooks, no env injection, no process tree detection (v1 keeps it simple)
|
||||
|
||||
## Future Work (Not In This Plan)
|
||||
|
||||
- Auto-detect agent type from PTY process tree (for standalone terminals)
|
||||
- "Convert to chat" / "Convert to terminal" actions
|
||||
- Terminal title/icon from OSC sequences
|
||||
- `paseo run --terminal` CLI support
|
||||
- Mobile terminal mode (if xterm.js works well enough on mobile web)
|
||||
- Gemini / Aider / Goose provider definitions (terminal-only providers)
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||
npmDepsHash = "sha256-WUTgYClIpHYsIrKjmlxvYxM6dS9jkYtAS0WLAH2nR/I=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
1527
package-lock.json
generated
1527
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.38",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -37,23 +37,27 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
|
||||
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
|
||||
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
|
||||
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
|
||||
"release:finalize": "node scripts/finalize-current-release.mjs",
|
||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-rc.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
106
packages/app/e2e/archive-tab.spec.ts
Normal file
106
packages/app/e2e/archive-tab.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.close();
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
257
packages/app/e2e/helpers/archive-tab.ts
Normal file
257
packages/app/e2e/helpers/archive-tab.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
export type ArchiveTabAgent = {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
type ArchiveTabDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: ArchiveTabDaemonClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
initialPrompt: "Reply with exactly READY.",
|
||||
});
|
||||
const finished = await client.waitForFinish(created.id, 120_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: ArchiveTabDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === seedNonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon, preferences, seedNonce },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
export async function resetSeededPageState(page: Page): Promise<void> {
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
await page.goto("/");
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
export async function openWorkspaceWithAgents(
|
||||
page: Page,
|
||||
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceArchiveOutcome(
|
||||
page: Page,
|
||||
input: { archivedAgentId: string; survivingAgentId: string },
|
||||
): Promise<void> {
|
||||
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, input.title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const box = await row.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(900);
|
||||
await page.mouse.up();
|
||||
|
||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||
await archiveButton.click();
|
||||
await expectSessionRowArchived(page, input.title);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||
|
||||
/** Wait for the workspace tab bar to be visible. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||
if (testId) ids.push(testId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||
const ids = await getTabTestIds(page);
|
||||
return ids.filter((id) => id.includes(kind)).length;
|
||||
}
|
||||
|
||||
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
// Fallback: the tab with focused styling
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-tab");
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Meta+t");
|
||||
}
|
||||
|
||||
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||
|
||||
/** Wait for the launcher panel to render with its primary tiles. */
|
||||
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "New Chat" tile. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "Terminal" tile. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||
|
||||
/** Click the "New Chat" tile on the launcher panel. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the "Terminal" tile on the launcher panel. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||
await tile.click();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
|
||||
/** Wait for any tab in the bar to display the given title text. */
|
||||
export async function waitForTabWithTitle(
|
||||
page: Page,
|
||||
title: string | RegExp,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||
.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab");
|
||||
// There might be multiple panes, each with a "+" button
|
||||
// But within a single pane there should only be one
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||
* Returns elapsed milliseconds.
|
||||
*/
|
||||
export async function measureTileTransition(
|
||||
page: Page,
|
||||
clickAction: () => Promise<void>,
|
||||
successLocator: ReturnType<Page["locator"]>,
|
||||
timeout = 5_000,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
await clickAction();
|
||||
await expect(successLocator).toBeVisible({ timeout });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||
* Returns all unique snapshots observed.
|
||||
*/
|
||||
export async function sampleTabsDuringTransition(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
durationMs = 2_000,
|
||||
intervalMs = 30,
|
||||
): Promise<string[][]> {
|
||||
const snapshots: string[][] = [];
|
||||
const startSampling = async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < durationMs) {
|
||||
snapshots.push(await getTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
const samplingPromise = startSampling();
|
||||
await action();
|
||||
await samplingPromise;
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||
return createTempGitRepo(prefix);
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
waitForLauncherPanel,
|
||||
assertProviderTilesVisible,
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
clickProviderTile,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
} from "./helpers/launcher";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
waitForTerminalContent,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Launcher Tab Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher tab", () => {
|
||||
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
await waitForLauncherPanel(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
await assertProviderTilesVisible(page);
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||
|
||||
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||
});
|
||||
|
||||
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Launcher tab should have been replaced (not added alongside)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
|
||||
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
// Total tab count should stay the same (replaced, not added)
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Tab count stays the same (in-place replacement)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
|
||||
// The launcher tab is gone, a terminal tab exists
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
// Click the first visible provider tile under "Terminal Agents"
|
||||
const providerTiles = page.locator('[role="button"]').filter({
|
||||
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||
});
|
||||
|
||||
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The provider grid follows the label. Click the first provider tile.
|
||||
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||
const firstProvider = providerGrid.getByRole("button").first();
|
||||
if (await firstProvider.isVisible().catch(() => false)) {
|
||||
await firstProvider.click();
|
||||
} else {
|
||||
// Fallback: look for any provider button after the section label
|
||||
const allButtons = page.getByRole("button");
|
||||
const count = await allButtons.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = allButtons.nth(i);
|
||||
const text = await btn.innerText().catch(() => "");
|
||||
// Skip known non-provider buttons
|
||||
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||
if (!text.trim()) continue;
|
||||
await btn.click();
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
if (!clicked) {
|
||||
test.skip(true, "No provider tiles available");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Should see an agent panel (terminal surface or agent stream)
|
||||
const agentOrTerminal = page.locator(
|
||||
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||
);
|
||||
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Tab count stays the same (replaced, not added)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await assertSingleNewTabButton(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open the terminal
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
}
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// No-Flash Transition Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher transitions (no flash)", () => {
|
||||
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
2_000,
|
||||
30,
|
||||
);
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||
const counts = snapshots.map((s) => s.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composer,
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft replacement is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.38",
|
||||
"version": "0.1.41",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.38",
|
||||
"@getpaseo/highlight": "0.1.38",
|
||||
"@getpaseo/server": "0.1.38",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.41",
|
||||
"@getpaseo/highlight": "0.1.41",
|
||||
"@getpaseo/server": "0.1.41",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Prevent white flash before React mounts */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #181B1A;
|
||||
}
|
||||
}
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -57,11 +56,10 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -70,7 +68,7 @@ import {
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
@@ -104,7 +102,7 @@ function PushNotificationRouter() {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
if (getIsElectronRuntime()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
@@ -343,12 +341,12 @@ function AppContainer({
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile,
|
||||
isMobile: isCompactLayout,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
@@ -364,20 +362,21 @@ function AppContainer({
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -477,6 +476,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
@@ -495,10 +495,13 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update title bar theme", error);
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme]);
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
@@ -603,6 +606,7 @@ function FaviconStatusSync() {
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -610,7 +614,7 @@ function RootStack() {
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -654,8 +658,10 @@ function NavigationActiveWorkspaceObserver() {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -69,9 +68,7 @@ export default function HostIndexRoute() {
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(
|
||||
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||
);
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export function createNativeFileAttachmentStore() {
|
||||
return createLocalFileAttachmentStore({
|
||||
@@ -8,8 +9,17 @@ export function createNativeFileAttachmentStore() {
|
||||
if (attachment.storageKey.startsWith("file://")) {
|
||||
return attachment.storageKey;
|
||||
}
|
||||
if (attachment.storageKey.startsWith("/")) {
|
||||
return `file://${attachment.storageKey}`;
|
||||
if (isAbsolutePath(attachment.storageKey)) {
|
||||
if (attachment.storageKey.startsWith("/")) {
|
||||
return `file://${attachment.storageKey}`;
|
||||
}
|
||||
|
||||
// UNC paths: \\server\share -> file://server/share
|
||||
if (attachment.storageKey.startsWith("\\\\")) {
|
||||
return `file:${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
return `file:///${attachment.storageKey.replace(/\\/g, "/")}`;
|
||||
}
|
||||
return attachment.storageKey;
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isDesktop()) {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
|
||||
24
packages/app/src/attachments/utils.test.ts
Normal file
24
packages/app/src/attachments/utils.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pathToFileUri } from "./utils";
|
||||
|
||||
describe("pathToFileUri", () => {
|
||||
it("converts POSIX absolute paths to file URIs", () => {
|
||||
expect(pathToFileUri("/home/user/file.txt")).toBe("file:///home/user/file.txt");
|
||||
});
|
||||
|
||||
it("converts Windows drive-letter paths to file URIs", () => {
|
||||
expect(pathToFileUri("C:\\Users\\file.txt")).toBe("file:///C:/Users/file.txt");
|
||||
});
|
||||
|
||||
it("converts UNC paths to host-based file URIs", () => {
|
||||
expect(pathToFileUri("\\\\server\\share\\dir")).toBe("file://server/share/dir");
|
||||
});
|
||||
|
||||
it("passes through file URIs unchanged", () => {
|
||||
expect(pathToFileUri("file:///already/uri")).toBe("file:///already/uri");
|
||||
});
|
||||
|
||||
it("passes through relative paths unchanged", () => {
|
||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export function generateAttachmentId(): string {
|
||||
return `att_${generateMessageId()}`;
|
||||
@@ -53,10 +54,21 @@ export function pathToFileUri(path: string): string {
|
||||
if (path.startsWith("file://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (!isAbsolutePath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path.startsWith("/")) {
|
||||
return `file://${path}`;
|
||||
}
|
||||
return path;
|
||||
|
||||
// UNC paths: \\server\share -> file://server/share
|
||||
if (path.startsWith("\\\\")) {
|
||||
return `file:${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
return `file:///${path.replace(/\\/g, "/")}`;
|
||||
}
|
||||
|
||||
export function fileUriToPath(uri: string): string {
|
||||
|
||||
@@ -1444,11 +1444,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -56,12 +56,11 @@ type QueuedMessage = {
|
||||
|
||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||
|
||||
interface ComposerProps {
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
@@ -90,12 +89,11 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
|
||||
export function Composer({
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
@@ -111,8 +109,8 @@ export function Composer({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: ComposerProps) {
|
||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -482,7 +480,7 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||
console.error("[Composer] Failed to start voice mode", error);
|
||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||
if (message && message.trim().length > 0) {
|
||||
@@ -679,7 +677,6 @@ export function Composer({
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||
images={selectedImages}
|
||||
@@ -15,8 +15,7 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -131,7 +130,6 @@ function SessionRow({
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -147,21 +145,12 @@ function SessionRow({
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.terminal ? (
|
||||
<SessionBadge
|
||||
label="Terminal"
|
||||
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
) : null}
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge
|
||||
label="Archived"
|
||||
@@ -250,7 +239,6 @@ export function AgentList({
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
requestReopen: agent.terminal && agent.status === "closed",
|
||||
});
|
||||
router.navigate(route as any);
|
||||
},
|
||||
@@ -445,11 +433,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
|
||||
|
||||
describe("isProviderModelsQueryLoading", () => {
|
||||
it("does not treat a disabled pending query as loading", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: ["providerModels", "server-1", "__missing_provider__"],
|
||||
enabled: false,
|
||||
queryFn: async () => [],
|
||||
});
|
||||
|
||||
const result = observer.getCurrentResult();
|
||||
|
||||
expect(result.isPending).toBe(true);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.isFetching).toBe(false);
|
||||
expect(isProviderModelsQueryLoading(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an active fetch as loading", () => {
|
||||
expect(
|
||||
isProviderModelsQueryLoading({
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
interface ProviderModelsQueryState {
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
|
||||
return input.isLoading || input.isFetching;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -634,12 +635,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerModels",
|
||||
serverId,
|
||||
agent?.provider ?? "__missing_provider__",
|
||||
agent?.cwd ?? "__missing_cwd__",
|
||||
],
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
@@ -753,7 +749,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1127,14 +1127,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
scrollToBottomIcon: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -268,10 +268,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
57
packages/app/src/components/desktop/titlebar-drag-region.tsx
Normal file
57
packages/app/src/components/desktop/titlebar-drag-region.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
/**
|
||||
* VS Code-style titlebar drag region for Electron.
|
||||
*
|
||||
* Copied from VS Code at commit daa0a70:
|
||||
* - titlebarPart.ts:463-464 → prepend(container, $('div.titlebar-drag-region'))
|
||||
* - titlebarpart.css:57-64 → position: absolute, full size, -webkit-app-region: drag
|
||||
* - titlebarpart.css:249-260 → top-edge resizer, no-drag, 4px
|
||||
*
|
||||
* VS Code's drag region is a static DOM element — no z-index, no pointer-events,
|
||||
* no state, no event listeners. Interactive elements get no-drag from their own
|
||||
* CSS (global backstop in index.html). The drag region never re-renders.
|
||||
*
|
||||
* The resizer is Windows/Linux only (titlebarpart.css:249 scopes to .windows/.linux).
|
||||
* On macOS, Electron handles edge resize natively.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
|
||||
* Place as FIRST child of any positioned container that should be draggable.
|
||||
*/
|
||||
export function TitlebarDragRegion() {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
|
||||
<div
|
||||
style={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "drag",
|
||||
}}
|
||||
/>
|
||||
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: 4,
|
||||
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
|
||||
WebkitAppRegion: "no-drag",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -108,11 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -40,7 +41,7 @@ export function ExplorerSidebar({
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -344,6 +345,7 @@ function SidebarContent({
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
{/* Header with tabs and close button */}
|
||||
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.tabsContainer}>
|
||||
{isGit && (
|
||||
<Pressable
|
||||
@@ -434,6 +436,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
position: "relative",
|
||||
height: HEADER_INNER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -119,7 +119,7 @@ function FilePreviewBody({
|
||||
filePath,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
|
||||
|
||||
@@ -86,9 +86,8 @@ interface HighlightedTextProps {
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
|
||||
// Get color for a highlight style
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
@@ -43,7 +44,7 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -24,14 +26,12 @@ interface ScreenHeaderProps {
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const padding = useWindowControlsPadding("header");
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
@@ -44,8 +44,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
|
||||
},
|
||||
borderless && styles.borderless,
|
||||
]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</View>
|
||||
@@ -60,6 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
inner: {},
|
||||
row: {
|
||||
position: "relative",
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
md: HEADER_INNER_HEIGHT,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AiderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface GeminiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenCodeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface PaseoLogoProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PaseoLogo({ size = 64, color = "white" }: PaseoLogoProps) {
|
||||
export function PaseoLogo({ size = 64, color }: PaseoLogoProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const fill = color ?? theme.colors.foreground;
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 700 700" fill="none">
|
||||
<Path
|
||||
d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z"
|
||||
fill={color}
|
||||
fill={fill}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -13,7 +13,7 @@ export function KeyboardShortcutsDialog() {
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||
[isDesktopApp, isMac],
|
||||
|
||||
@@ -20,7 +20,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -35,11 +35,16 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
buildHostSessionsRoute,
|
||||
buildHostSettingsRoute,
|
||||
@@ -94,6 +99,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
isOpen: boolean;
|
||||
handleViewMore: () => void;
|
||||
}
|
||||
@@ -105,7 +111,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -175,7 +181,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
@@ -265,7 +271,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleHostSelect,
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
@@ -283,6 +289,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -627,11 +634,11 @@ function DesktopSidebar({
|
||||
handleHostSelect,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
@@ -681,11 +688,14 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -824,6 +834,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 10,
|
||||
zIndex: 10,
|
||||
},
|
||||
sidebarDragArea: {
|
||||
position: "relative",
|
||||
},
|
||||
sidebarHeader: {
|
||||
height: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||
|
||||
@@ -53,7 +53,6 @@ export interface MessageInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
allowEmptySubmit?: boolean;
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
images?: ImageAttachment[];
|
||||
@@ -179,7 +178,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
@@ -853,7 +851,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
|
||||
@@ -269,10 +269,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
...theme.shadow.lg,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { AiderIcon } from "@/components/icons/aider-icon";
|
||||
import { AmpIcon } from "@/components/icons/amp-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
gemini: GeminiIcon as unknown as typeof Bot,
|
||||
amp: AmpIcon as unknown as typeof Bot,
|
||||
aider: AiderIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -42,9 +42,10 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -82,8 +83,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
@@ -238,7 +239,7 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "checkout"
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
@@ -653,6 +654,7 @@ function ProjectHeaderRow({
|
||||
canCreateWorktree,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
@@ -662,32 +664,51 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
|
||||
|
||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
onWorkspacePress?.();
|
||||
beginWorkspaceSetup({
|
||||
serverId,
|
||||
projectPath: project.iconWorkingDir,
|
||||
projectName: displayName,
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
const isMobileBreakpoint = isCompactFormFactor();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
|
||||
const createWorktreeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!serverId) {
|
||||
throw new Error("No server");
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: project.iconWorkingDir,
|
||||
worktreeSlug: createNameId(),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? "Failed to create worktree");
|
||||
}
|
||||
return payload.workspace;
|
||||
},
|
||||
onSuccess: (workspace) => {
|
||||
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
|
||||
onWorktreeCreated?.(workspace.id);
|
||||
onWorkspacePress?.();
|
||||
router.navigate(
|
||||
prepareWorkspaceTab({
|
||||
serverId: serverId!,
|
||||
workspaceId: workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-new-${project.projectKey}`,
|
||||
actions: ["worktree.new"],
|
||||
enabled: isProjectActive && canCreateWorktree,
|
||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleBeginWorkspaceSetup();
|
||||
createWorktreeMutation.mutate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -731,9 +752,9 @@ function ProjectHeaderRow({
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={handleBeginWorkspaceSetup}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={false}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
@@ -814,11 +835,11 @@ function WorkspaceRowInner({
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = Platform.OS !== "web";
|
||||
const isTouchPlatform = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.projectKind === "git",
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
});
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -879,7 +900,7 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
@@ -1074,7 +1095,7 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1219,7 +1240,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1330,7 +1351,7 @@ function FlattenedProjectRow({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
}) {
|
||||
if (project.projectKind === "directory") {
|
||||
if (project.projectKind === "non_git") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
@@ -1616,7 +1637,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isNative = Platform.OS !== "web";
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
@@ -1624,7 +1645,7 @@ export function SidebarWorkspaceList({
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
|
||||
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
|
||||
@@ -2001,11 +2022,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
...theme.shadow.md,
|
||||
},
|
||||
projectRowLeft: {
|
||||
flexDirection: "row",
|
||||
@@ -2148,11 +2165,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
elevation: 4,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
...theme.shadow.md,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import {
|
||||
computeTabDropPreview,
|
||||
type TabDropPreview,
|
||||
@@ -86,7 +87,12 @@ interface SplitContainerProps {
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||
onSelectNewTabOption: (selection: {
|
||||
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
paneId?: string;
|
||||
}) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
isPaneFocused: boolean;
|
||||
@@ -262,7 +268,9 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateLauncherTab,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -529,7 +537,9 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -649,7 +659,9 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateLauncherTab,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -682,7 +694,9 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -730,7 +744,9 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -777,7 +793,9 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onCreateLauncherTab,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -870,6 +888,7 @@ function SplitPaneView({
|
||||
{ paddingLeft: padding.left, paddingRight: padding.right },
|
||||
]}
|
||||
>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused}
|
||||
@@ -885,7 +904,9 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(
|
||||
pane.id,
|
||||
@@ -978,6 +999,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
paneTabs: {
|
||||
position: "relative",
|
||||
minWidth: 0,
|
||||
},
|
||||
paneContent: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -91,7 +92,7 @@ export function TerminalPane({
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
|
||||
@@ -259,11 +259,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
@@ -248,11 +248,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
detailLabel: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -275,11 +271,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
|
||||
@@ -136,9 +136,11 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
const color = variant === "ghost"
|
||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
||||
: theme.colors.foreground;
|
||||
const color = variant === "default"
|
||||
? theme.colors.accentForeground
|
||||
: variant === "ghost"
|
||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
||||
: theme.colors.foreground;
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
|
||||
@@ -845,11 +845,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxHeight: 400,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -722,11 +722,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetBackground: {
|
||||
|
||||
@@ -605,11 +605,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
labelContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -535,11 +535,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.popover,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.border,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
...theme.shadow.sm,
|
||||
zIndex: 1000,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,11 +25,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
buttonIdle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -350,8 +350,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
? HANDLE_OPACITY_VISIBLE
|
||||
: 0;
|
||||
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
|
||||
const handleColor = theme.colors.scrollbarHandle;
|
||||
const handleCursor = isDragging ? "grabbing" : "grab";
|
||||
const handleTravelDurationMs =
|
||||
isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS;
|
||||
|
||||
@@ -320,7 +320,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
testID="welcome-screen"
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} color={theme.colors.foreground} />
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { MessagePayload } from "./message-input";
|
||||
|
||||
type SetupStep = "choose" | "chat" | "terminal-agent";
|
||||
|
||||
export function WorkspaceSetupDialog() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [step, setStep] = useState<SetupStep>("choose");
|
||||
const [terminalPrompt, setTerminalPrompt] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
|
||||
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey: `workspace-setup:${serverId}:${projectPath}`,
|
||||
composer: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspace?.id ?? projectPath,
|
||||
},
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
if (!composerState && pendingWorkspaceSetup) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
const { providers: sortedProviders, recordUsage } = useProviderRecency(
|
||||
composerState?.providerDefinitions ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStep("choose");
|
||||
setTerminalPrompt("");
|
||||
setErrorMessage(null);
|
||||
setCreatedWorkspace(null);
|
||||
setPendingAction(null);
|
||||
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearWorkspaceSetup();
|
||||
}, [clearWorkspaceSetup]);
|
||||
|
||||
const navigateAfterCreation = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
|
||||
) => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
navigationMethod: pendingWorkspaceSetup.navigationMethod,
|
||||
});
|
||||
},
|
||||
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
||||
);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected]);
|
||||
|
||||
const ensureWorkspace = useCallback(async () => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
throw new Error("No workspace setup is pending");
|
||||
}
|
||||
|
||||
if (createdWorkspace) {
|
||||
return createdWorkspace;
|
||||
}
|
||||
|
||||
const connectedClient = withConnectedClient();
|
||||
const payload =
|
||||
pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? await connectedClient.createPaseoWorktree({
|
||||
cwd: pendingWorkspaceSetup.projectPath,
|
||||
worktreeSlug: createNameId(),
|
||||
})
|
||||
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(
|
||||
payload.error ??
|
||||
(pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? "Failed to create worktree"
|
||||
: "Failed to open project"),
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
|
||||
if (pendingWorkspaceSetup.creationMethod === "open_project") {
|
||||
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
|
||||
}
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
}, [
|
||||
createdWorkspace,
|
||||
mergeWorkspaces,
|
||||
pendingWorkspaceSetup,
|
||||
setHasHydratedWorkspaces,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const getIsStillActive = useCallback(() => {
|
||||
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
|
||||
return (
|
||||
current?.serverId === pendingWorkspaceSetup?.serverId &&
|
||||
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
|
||||
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
||||
);
|
||||
}, [
|
||||
pendingWorkspaceSetup?.creationMethod,
|
||||
pendingWorkspaceSetup?.projectPath,
|
||||
pendingWorkspaceSetup?.serverId,
|
||||
]);
|
||||
|
||||
const handleCreateChatAgent = useCallback(
|
||||
async ({ text, images }: MessagePayload) => {
|
||||
try {
|
||||
setPendingAction("chat");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const encodedImages = await encodeImages(images);
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(text.trim() ? { initialPrompt: text.trim() } : {}),
|
||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateTerminalAgent = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal-agent");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
terminal: true,
|
||||
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordUsage(composerState.selectedProvider);
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
recordUsage,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
terminalPrompt,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const handleCreateTerminal = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
|
||||
const payload = await connectedClient.createTerminal(workspace.id);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
|
||||
|
||||
const workspaceTitle =
|
||||
workspace?.name ||
|
||||
workspace?.projectDisplayName ||
|
||||
projectName ||
|
||||
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
|
||||
projectPath;
|
||||
const workspacePath = workspace?.projectRootPath || projectPath;
|
||||
|
||||
if (!pendingWorkspaceSetup || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Set up workspace"
|
||||
visible={true}
|
||||
onClose={handleClose}
|
||||
snapPoints={["82%", "94%"]}
|
||||
testID="workspace-setup-dialog"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
|
||||
<Text style={styles.workspacePath}>{workspacePath}</Text>
|
||||
</View>
|
||||
|
||||
{step === "choose" ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>What do you want to open?</Text>
|
||||
<View style={styles.choiceGrid}>
|
||||
<ChoiceCard
|
||||
title="Chat Agent"
|
||||
description="Open this workspace with a prompt-first chat agent."
|
||||
Icon={MessagesSquare}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("chat");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal Agent"
|
||||
description="Launch an agent-backed terminal in an agent tab."
|
||||
Icon={Bot}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("terminal-agent");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal"
|
||||
description="Create the workspace, then open a standalone terminal tab."
|
||||
Icon={SquareTerminal}
|
||||
disabled={pendingAction !== null}
|
||||
pending={pendingAction === "terminal"}
|
||||
onPress={() => {
|
||||
void handleCreateTerminal();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "chat" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Chat Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
|
||||
</Text>
|
||||
<View style={styles.composerCard}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${projectPath}`}
|
||||
serverId={serverId}
|
||||
isInputActive={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
images={chatDraft.images}
|
||||
onChangeImages={chatDraft.setImages}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={
|
||||
composerState
|
||||
? {
|
||||
...composerState.statusControls,
|
||||
disabled: pendingAction !== null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "terminal-agent" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Terminal Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
|
||||
</Text>
|
||||
|
||||
<View style={styles.providerGrid}>
|
||||
{sortedProviders.map((provider) => (
|
||||
<ProviderOption
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
selected={provider.id === composerState?.selectedProvider}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => composerState?.setProviderFromUser(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Initial prompt</Text>
|
||||
<AdaptiveTextInput
|
||||
value={terminalPrompt}
|
||||
onChangeText={setTerminalPrompt}
|
||||
placeholder="Optional"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
multiline
|
||||
autoCapitalize="sentences"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
void handleCreateTerminalAgent();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View style={styles.stepHeader}>
|
||||
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({
|
||||
title,
|
||||
description,
|
||||
Icon,
|
||||
disabled,
|
||||
pending = false,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
disabled: boolean;
|
||||
pending?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.choiceCard,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceIconWrap}>
|
||||
{pending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.choiceBody}>
|
||||
<Text style={styles.choiceTitle}>{title}</Text>
|
||||
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderOption({
|
||||
provider,
|
||||
selected,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
provider: { id: AgentProvider; label: string; description: string };
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.providerCard,
|
||||
selected ? styles.providerCardSelected : null,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.providerBody}>
|
||||
<Text style={styles.providerTitle}>{provider.label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspaceTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
workspacePath: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
helper: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
choiceGrid: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
choiceCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
choiceCardHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cardDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
choiceIconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
choiceBody: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
composerCard: {
|
||||
minHeight: 180,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
stepHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
providerCardSelected: {
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerBody: {
|
||||
flex: 1,
|
||||
},
|
||||
providerTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
textAlignVertical: "top",
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop, isDesktopMac } from "@/desktop/host";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
@@ -23,40 +24,59 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||
|
||||
// Check if running in desktop app (any OS)
|
||||
function isDesktopEnvironment(): boolean {
|
||||
// Check if running in the Electron desktop runtime (any OS)
|
||||
function isElectronDesktopRuntime(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isDesktop();
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
// Check if running in desktop host on macOS
|
||||
function isDesktopEnvironmentMac(): boolean {
|
||||
// Check if running in the Electron desktop runtime on macOS
|
||||
function isElectronDesktopRuntimeMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isDesktopMac();
|
||||
return isElectronRuntimeMac();
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
||||
let _isDesktopMacCached: boolean | null = null;
|
||||
let _isDesktopCached: boolean | null = null;
|
||||
let _isElectronRuntimeMacCached: boolean | null = null;
|
||||
let _isElectronRuntimeCached: boolean | null = null;
|
||||
|
||||
export function getIsDesktopMac(): boolean {
|
||||
if (_isDesktopMacCached === true) {
|
||||
export function getIsElectronRuntimeMac(): boolean {
|
||||
if (_isElectronRuntimeMacCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isDesktopEnvironmentMac();
|
||||
const result = isElectronDesktopRuntimeMac();
|
||||
if (result) {
|
||||
_isDesktopMacCached = true;
|
||||
_isElectronRuntimeMacCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIsDesktop(): boolean {
|
||||
if (_isDesktopCached === true) {
|
||||
export function getIsElectronRuntime(): boolean {
|
||||
if (_isElectronRuntimeCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isDesktopEnvironment();
|
||||
const result = isElectronDesktopRuntime();
|
||||
if (result) {
|
||||
_isDesktopCached = true;
|
||||
_isElectronRuntimeCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isCompactFormFactor(): boolean {
|
||||
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
}
|
||||
|
||||
export function isDesktopFormFactor(): boolean {
|
||||
return !isCompactFormFactor();
|
||||
}
|
||||
|
||||
export function isTouchDesktopFormFactor(): boolean {
|
||||
return Platform.OS !== "web" && isDesktopFormFactor();
|
||||
}
|
||||
|
||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||
// Keep that capability distinct from desktop-width layout so touch tablets
|
||||
// can use the desktop shell without entering web-only code paths.
|
||||
export function supportsDesktopPaneSplits(): boolean {
|
||||
return Platform.OS === "web";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getRightSidebarAnimationTargets,
|
||||
@@ -27,12 +27,12 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
||||
|
||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
@@ -521,8 +521,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.endSeq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
@@ -748,12 +749,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.endSeq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
@@ -856,6 +858,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
@@ -960,7 +963,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq } = message.payload;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
@@ -1002,6 +1005,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const result = processAgentStreamEvent({
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
@@ -1025,6 +1029,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (
|
||||
current &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string" &&
|
||||
current.epoch === epoch &&
|
||||
seq >= current.startSeq &&
|
||||
seq <= current.endSeq
|
||||
) {
|
||||
@@ -1033,6 +1039,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === nextCursor.epoch &&
|
||||
current.startSeq === nextCursor.startSeq &&
|
||||
current.endSeq === nextCursor.endSeq
|
||||
) {
|
||||
@@ -1083,7 +1090,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
|
||||
@@ -7,7 +7,6 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
terminal: false,
|
||||
status,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
@@ -20,7 +19,6 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -9,9 +9,13 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seq,
|
||||
seqStart: seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -29,30 +33,22 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "shell",
|
||||
status,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
item: { type: "user_message", text },
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -67,6 +63,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
event: makeTimelineEvent("hello"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
@@ -74,6 +71,10 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
timestamp: new Date(2000),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
@@ -92,10 +93,35 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||
const provisionalHead: StreamItem[] = [
|
||||
it("returns error with no init resolution when no deferred exists", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
error: "timeout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBe("timeout");
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces tail and clears head when reset=true", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "old",
|
||||
text: "old message",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const existingHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-1",
|
||||
@@ -106,280 +132,518 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: provisionalHead,
|
||||
currentTail: existingTail,
|
||||
currentHead: existingHead,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "first"),
|
||||
makeTimelineEntry(2, "second"),
|
||||
makeTimelineEntry(3, "third"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).not.toBe(existingTail);
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap tail replaces
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
|
||||
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||
expect(catchUp).toEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 5 },
|
||||
// Should have catch-up side effect
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("prepends older committed history for before pagination", () => {
|
||||
const currentTail: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "tail-3",
|
||||
text: "newer",
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||
it("appends incrementally for contiguous seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail,
|
||||
currentCursor,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
entries: [
|
||||
makeTimelineEntry(1, "hello", "user_message"),
|
||||
makeTimelineEntry(2, "older"),
|
||||
],
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.tail).toHaveLength(3);
|
||||
expect(result.tail[0]?.kind).toBe("user_message");
|
||||
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||
expect(result.tail[2]).toBe(currentTail[0]);
|
||||
expect(result.error).toBe(null);
|
||||
});
|
||||
|
||||
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(10, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
// Gap should trigger catch-up
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale entries silently", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
||||
},
|
||||
});
|
||||
|
||||
// No new items appended (all dropped as stale)
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-2",
|
||||
entries: [makeTimelineEntry(6, "different epoch")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves init when deferred matches direction", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 121,
|
||||
endSeq: 121,
|
||||
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
startSeq: 1,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "still streaming",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toBe(currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.initResolution).toBe("resolve");
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
// "before" direction doesn't match "after" initRequestDirection,
|
||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
});
|
||||
|
||||
it("clears initializing even without deferred", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 125,
|
||||
endSeq: 125,
|
||||
entries: [makeTimelineEntry(125, "far ahead")],
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
expect(result.initResolution).toBe(null);
|
||||
});
|
||||
|
||||
it("always includes flush_pending_updates side effect on success", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes cursor when no existing cursor on first entries", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("treats seq-less timeline events as provisional head updates", () => {
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("partial"),
|
||||
event: turnEvent,
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "partial",
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
it("accepts timeline event with cursor advance", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("finalized reply"),
|
||||
seq: 121,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
event: makeTimelineEvent("new chunk"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||
const provisional = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
});
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
|
||||
const committed = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: 8,
|
||||
currentHead: provisional.head,
|
||||
currentTail: provisional.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
|
||||
expect(committed.head).toEqual([]);
|
||||
expect(committed.tail).toHaveLength(1);
|
||||
expect(committed.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requests catch-up when a committed live row skips ahead", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("far ahead"),
|
||||
seq: 125,
|
||||
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||
seq: 10,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears provisional head on terminal turn events without committing it to tail", () => {
|
||||
it("drops stale timeline event", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
},
|
||||
currentHead: [
|
||||
{
|
||||
kind: "thought",
|
||||
id: "reasoning-1",
|
||||
text: "thinking",
|
||||
timestamp: new Date(1000),
|
||||
status: "loading",
|
||||
},
|
||||
],
|
||||
event: makeTimelineEvent("old"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops timeline event with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("wrong epoch"),
|
||||
seq: 6,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("first"),
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: undefined,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent).not.toBe(null);
|
||||
expect(result.agent!.status).toBe("idle");
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "something broke",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnFailedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "idle",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when no agent is provided", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: null,
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(5000),
|
||||
lastActivityAt: new Date(5000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("does not produce agent patch for non-terminal events", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("just text"),
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -12,25 +12,38 @@ import {
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared cursor type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineCursor = {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect discriminated unions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect = {
|
||||
type: "catch_up";
|
||||
cursor: { endSeq: number };
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seq: number;
|
||||
seqStart: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -40,8 +53,10 @@ export interface ProcessTimelineResponseInput {
|
||||
payload: {
|
||||
agentId: string;
|
||||
direction: TimelineDirection;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
startCursor: { seq: number } | null;
|
||||
endCursor: { seq: number } | null;
|
||||
entries: TimelineResponseEntry[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -64,9 +79,205 @@ export interface ProcessTimelineResponseOutput {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error path: reject init and leave stream state unchanged
|
||||
// ------------------------------------------------------------------
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
// ------------------------------------------------------------------
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
reset: payload.reset,
|
||||
epoch: payload.epoch,
|
||||
endCursor: payload.endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (replace) {
|
||||
// ----------------------------------------------------------------
|
||||
// Replace path: full hydration from scratch
|
||||
// ----------------------------------------------------------------
|
||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
});
|
||||
nextHead = [];
|
||||
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
nextCursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (timelineUnits.length > 0) {
|
||||
// ----------------------------------------------------------------
|
||||
// Incremental append path
|
||||
// ----------------------------------------------------------------
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
||||
epoch: payload.epoch,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
if (decision === "init") {
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cursor) {
|
||||
continue;
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cursor &&
|
||||
(!currentCursor ||
|
||||
currentCursor.epoch !== cursor.epoch ||
|
||||
currentCursor.startSeq !== cursor.startSeq ||
|
||||
currentCursor.endSeq !== cursor.endSeq)
|
||||
) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flush pending agent updates side effect
|
||||
// ------------------------------------------------------------------
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Init resolution
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
@@ -96,250 +307,75 @@ export interface ProcessAgentStreamEventOutput {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
function cursorsEqual(
|
||||
left: TimelineCursor | null | undefined,
|
||||
right: TimelineCursor | null | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||
}
|
||||
|
||||
function removeSupersededProvisionalItems(
|
||||
head: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
): StreamItem[] {
|
||||
if (head.length === 0 || event.type !== "timeline") {
|
||||
return head;
|
||||
}
|
||||
|
||||
let nextHead = head;
|
||||
if (event.item.type === "assistant_message") {
|
||||
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||
} else if (event.item.type === "tool_call") {
|
||||
const committedToolCall = event.item;
|
||||
nextHead = head.filter(
|
||||
(item) =>
|
||||
item.kind !== "tool_call" ||
|
||||
item.payload.source !== "agent" ||
|
||||
item.payload.data.callId !== committedToolCall.callId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextHead.length === head.length ? head : nextHead;
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
endSeq: payload.endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (bootstrapPolicy.replace) {
|
||||
nextTail = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextHead = [];
|
||||
nextCursor =
|
||||
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: payload.endSeq,
|
||||
}
|
||||
: null;
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (payload.direction === "before") {
|
||||
const prepended = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||
const derivedCursor =
|
||||
typeof payload.startSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||
}
|
||||
: currentCursor;
|
||||
nextCursor = derivedCursor;
|
||||
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||
} else if (timelineUnits.length > 0) {
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
cursor =
|
||||
decision === "init"
|
||||
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
if (event.type === "timeline" && typeof seq === "number") {
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: currentCursor.endSeq },
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (decision !== "drop_stale") {
|
||||
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||
source: "canonical",
|
||||
});
|
||||
changedTail = nextTail !== currentTail;
|
||||
|
||||
nextHead = removeSupersededProvisionalItems(currentHead, event);
|
||||
changedHead = nextHead !== currentHead;
|
||||
|
||||
nextTimelineCursor =
|
||||
decision === "init"
|
||||
? { startSeq: seq, endSeq: seq }
|
||||
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
}
|
||||
} else if (event.type === "timeline") {
|
||||
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||
source: "live",
|
||||
});
|
||||
changedHead = nextHead !== currentHead;
|
||||
} else if (
|
||||
(event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed") &&
|
||||
currentHead.length > 0
|
||||
) {
|
||||
nextHead = [];
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
})
|
||||
: {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
@@ -366,8 +402,8 @@ export function processAgentStreamEvent(
|
||||
}
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
tail,
|
||||
head,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
|
||||
@@ -2,42 +2,26 @@ import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
deriveInitialTimelineRequest,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("uses tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { endSeq: 205 };
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
@@ -45,6 +29,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
@@ -55,13 +40,16 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
endSeq: 200,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
@@ -69,7 +57,9 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
endSeq: 200,
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
@@ -87,6 +77,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -98,6 +89,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -109,6 +101,7 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ type BootstrapTailCursor = {
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
@@ -19,37 +20,48 @@ export function deriveInitialTimelineRequest({
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { seq: number };
|
||||
cursor?: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { seq: cursor.seq },
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
endSeq,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
endSeq: number | null;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { endSeq: number } | null;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
@@ -57,7 +69,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,14 +78,19 @@ export function shouldResolveTimelineInit({
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 4 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
@@ -14,7 +15,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 8 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
@@ -23,16 +25,28 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 8 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
@@ -41,7 +55,8 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { endSeq: 4 },
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
@@ -34,12 +34,12 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
||||
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
// Initialize based on current state
|
||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
export function DesktopPermissionsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
@@ -20,7 +20,7 @@ export function DesktopPermissionsSection() {
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDesktopHost, isDesktop } from "@/desktop/host";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
@@ -123,7 +123,7 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isDesktop();
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getDesktopHost, type DesktopWindowBridge } from "@/desktop/host";
|
||||
import {
|
||||
getDesktopHost,
|
||||
type DesktopWindowBridge,
|
||||
type DesktopWindowControlsOverlayUpdate,
|
||||
} from "@/desktop/host";
|
||||
|
||||
export function getDesktopWindow(): DesktopWindowBridge | null {
|
||||
const getter = getDesktopHost()?.window?.getCurrentWindow;
|
||||
@@ -28,11 +32,13 @@ export async function isDesktopFullscreen(): Promise<boolean> {
|
||||
return await win.isFullscreen();
|
||||
}
|
||||
|
||||
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
|
||||
export async function updateDesktopWindowControls(
|
||||
update: DesktopWindowControlsOverlayUpdate,
|
||||
): Promise<void> {
|
||||
const win = getDesktopWindow();
|
||||
if (!win || typeof win.setTitleBarTheme !== "function") {
|
||||
if (!win || typeof win.updateWindowControls !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
await win.setTitleBarTheme(theme);
|
||||
await win.updateWindowControls(update);
|
||||
}
|
||||
|
||||
@@ -44,14 +44,17 @@ export interface DesktopMenuBridge {
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopWindowControlsOverlayUpdate {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}
|
||||
|
||||
export interface DesktopWindowBridge {
|
||||
label?: string;
|
||||
startMove?: (screenX: number, screenY: number) => void;
|
||||
moving?: (screenX: number, screenY: number) => void;
|
||||
endMove?: () => void;
|
||||
toggleMaximize?: () => Promise<void>;
|
||||
isFullscreen?: () => Promise<boolean>;
|
||||
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
|
||||
updateWindowControls?: (update: DesktopWindowControlsOverlayUpdate) => Promise<void>;
|
||||
onResized?: <TEvent = unknown>(
|
||||
handler: (event: TEvent) => void,
|
||||
) => Promise<() => void> | (() => void);
|
||||
@@ -97,12 +100,12 @@ export function getDesktopHost(): DesktopHostBridge | null {
|
||||
return getElectronHost();
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
export function isElectronRuntime(): boolean {
|
||||
return getDesktopHost() !== null;
|
||||
}
|
||||
|
||||
export function isDesktopMac(): boolean {
|
||||
if (!isDesktop()) {
|
||||
export function isElectronRuntimeMac(): boolean {
|
||||
if (!isElectronRuntime()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof navigator === "undefined") {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
|
||||
export interface UseDesktopPermissionsReturn {
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
snapshot: DesktopPermissionSnapshot | null;
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
@@ -31,7 +31,7 @@ const EMPTY_MICROPHONE_STATUS = {
|
||||
};
|
||||
|
||||
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const isDesktop = shouldShowDesktopPermissionSection();
|
||||
const isDesktopApp = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -47,7 +47,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
}, []);
|
||||
|
||||
const refreshPermissions = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,11 +65,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const requestPermission = useCallback(
|
||||
async (kind: DesktopPermissionKind) => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,13 +110,13 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
await refreshPermissions();
|
||||
}
|
||||
},
|
||||
[isDesktop, refreshPermissions],
|
||||
[isDesktopApp, refreshPermissions],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,18 +137,18 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshPermissions();
|
||||
}, [isDesktop, refreshPermissions]);
|
||||
}, [isDesktopApp, refreshPermissions]);
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpdateSection(): boolean {
|
||||
return Platform.OS === "web" && isDesktop();
|
||||
return Platform.OS === "web" && isElectronRuntime();
|
||||
}
|
||||
|
||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||
|
||||
@@ -11,7 +11,7 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
export function UpdateBanner() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
status,
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
@@ -23,7 +23,7 @@ export function UpdateBanner() {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
if (!isDesktopApp) return;
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
|
||||
@@ -36,9 +36,9 @@ export function UpdateBanner() {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isDesktop, checkForUpdates]);
|
||||
}, [isDesktopApp, checkForUpdates]);
|
||||
|
||||
if (!isDesktop) return null;
|
||||
if (!isDesktopApp) return null;
|
||||
if (dismissed) return null;
|
||||
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
|
||||
return null;
|
||||
@@ -128,11 +128,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
...theme.shadow.md,
|
||||
maxWidth: 480,
|
||||
},
|
||||
closeButton: {
|
||||
|
||||
@@ -18,7 +18,7 @@ export type DesktopAppUpdateStatus =
|
||||
| "error";
|
||||
|
||||
export interface UseDesktopAppUpdaterReturn {
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
status: DesktopAppUpdateStatus;
|
||||
statusText: string;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
@@ -75,7 +75,7 @@ function formatStatusText(input: {
|
||||
}
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktop = shouldShowDesktopUpdateSection();
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
@@ -85,7 +85,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
|
||||
const checkForUpdates = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -130,11 +130,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isDesktop],
|
||||
[isDesktopApp],
|
||||
);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(message);
|
||||
return null;
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
status,
|
||||
statusText: formatStatusText({
|
||||
status,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||
|
||||
@@ -29,12 +30,8 @@ const IMAGE_FILE_EXTENSIONS = [
|
||||
"svg",
|
||||
];
|
||||
|
||||
function isAbsoluteWindowsPath(value: string): boolean {
|
||||
return /^[a-zA-Z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
function shouldTreatAsFileUri(uri: string): boolean {
|
||||
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
|
||||
return uri.startsWith("file://") || isAbsolutePath(uri);
|
||||
}
|
||||
|
||||
async function blobFromUri(uri: string): Promise<Blob> {
|
||||
|
||||
@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
|
||||
onlineServerIds?: string[];
|
||||
};
|
||||
|
||||
export type UseAgentFormStateResult = {
|
||||
type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const providerModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
@@ -446,7 +446,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: providerDefinitions.map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
@@ -14,10 +15,11 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
@@ -27,13 +29,15 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
@@ -41,8 +45,9 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +61,7 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
@@ -107,6 +107,7 @@ export function useAgentInitialization({
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
asyncStorage.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
asyncStorage.delete(key);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/service", () => ({
|
||||
garbageCollectAttachments: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./use-agent-form-state", () => ({
|
||||
useAgentFormState: () => ({
|
||||
selectedServerId: "host-1",
|
||||
setSelectedServerId: () => undefined,
|
||||
setSelectedServerIdFromUser: () => undefined,
|
||||
selectedProvider: "codex",
|
||||
setProviderFromUser: () => undefined,
|
||||
selectedMode: "auto",
|
||||
setModeFromUser: () => undefined,
|
||||
selectedModel: "",
|
||||
setModelFromUser: () => undefined,
|
||||
selectedThinkingOptionId: "",
|
||||
setThinkingOptionFromUser: () => undefined,
|
||||
workingDir: "/repo",
|
||||
setWorkingDir: () => undefined,
|
||||
setWorkingDirFromUser: () => undefined,
|
||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
||||
providerDefinitionMap: new Map(),
|
||||
agentDefinition: undefined,
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
availableModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
allProviderModels: new Map([
|
||||
[
|
||||
"codex",
|
||||
[
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
isAllModelsLoading: false,
|
||||
availableThinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
isModelLoading: false,
|
||||
modelError: null,
|
||||
refreshProviderModels: () => undefined,
|
||||
setProviderAndModelFromUser: () => undefined,
|
||||
workingDirIsEmpty: false,
|
||||
persistFormPreferences: async () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
});
|
||||
|
||||
it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
id: "attachment-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/1",
|
||||
createdAt: 1,
|
||||
fileName: "image.png",
|
||||
byteSize: 128,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe({ draftKey }: { draftKey: string }) {
|
||||
latest = useAgentInputDraft({
|
||||
draftKey,
|
||||
composer: {
|
||||
initialServerId: "host-1",
|
||||
initialValues: { workingDir: "/repo" },
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
lockedWorkingDir: "/repo",
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
let root: Root | null = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("hello world");
|
||||
getLatest().setImages([image]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.unmount();
|
||||
});
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("hello world");
|
||||
expect(getLatest().images).toEqual([image]);
|
||||
});
|
||||
|
||||
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const sentImage: AttachmentMetadata = {
|
||||
id: "attachment-sent",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/sent",
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setImages([sentImage]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("sent");
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("");
|
||||
expect(getLatest().images).toEqual([]);
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "sent",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("draft again");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("abandoned");
|
||||
});
|
||||
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "abandoned",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let __private__: typeof import("./use-agent-input-draft").__private__;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ __private__ } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft", () => {
|
||||
describe("__private__.resolveDraftKey", () => {
|
||||
it("returns an object draft key string unchanged", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: "draft:key",
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:key");
|
||||
});
|
||||
|
||||
it("resolves a computed draft key from the selected server", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:host-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "gpt-5.4-mini",
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected model when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "gpt-5.4-mini",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("falls back to the provider default model", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
type UseAgentFormStateResult,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
||||
|
||||
type AgentInputDraftComposerOptions = {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
};
|
||||
|
||||
type DraftKeyContext = {
|
||||
selectedServerId: string | null;
|
||||
};
|
||||
|
||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||
|
||||
type UseAgentInputDraftInput = {
|
||||
draftKey: DraftKeyInput;
|
||||
composer?: AgentInputDraftComposerOptions;
|
||||
};
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
statusControls: DraftAgentStatusBarProps;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
@@ -46,7 +11,6 @@ interface AgentInputDraft {
|
||||
setImages: (updater: ImageUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
||||
@@ -72,108 +36,7 @@ function areImagesEqual(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDraftKey(input: {
|
||||
draftKey: DraftKeyInput;
|
||||
selectedServerId: string | null;
|
||||
}): string {
|
||||
if (typeof input.draftKey === "function") {
|
||||
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||
}
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
const selectedModel = input.selectedModel.trim();
|
||||
if (selectedModel) {
|
||||
return selectedModel;
|
||||
}
|
||||
|
||||
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
function buildDraftComposerCommandConfig(input: {
|
||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
}): DraftAgentStatusBarProps {
|
||||
const { formState } = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
onSelectProvider: formState.setProviderFromUser,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
onSelectMode: formState.setModeFromUser,
|
||||
models: formState.availableModels,
|
||||
selectedModel: formState.selectedModel,
|
||||
onSelectModel: formState.setModelFromUser,
|
||||
isModelLoading: formState.isModelLoading,
|
||||
allProviderModels: formState.allProviderModels,
|
||||
isAllModelsLoading: formState.isAllModelsLoading,
|
||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||
thinkingOptions: formState.availableThinkingOptions,
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||
const composerOptions = input.composer ?? null;
|
||||
const formState = useAgentFormState({
|
||||
initialServerId: composerOptions?.initialServerId ?? null,
|
||||
initialValues: composerOptions?.initialValues,
|
||||
isVisible: composerOptions?.isVisible ?? false,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
||||
});
|
||||
const draftKey = useMemo(
|
||||
() =>
|
||||
resolveDraftKey({
|
||||
draftKey: input.draftKey,
|
||||
selectedServerId: formState.selectedServerId,
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
@@ -285,83 +148,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
});
|
||||
}, [draftKey, images, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
if (formState.workingDir.trim() === lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||
if (!composerOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
statusControls: buildDraftStatusControls({ formState }),
|
||||
commandDraftConfig,
|
||||
};
|
||||
}, [
|
||||
commandDraftConfig,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
formState,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
@@ -369,14 +155,5 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
setImages,
|
||||
clear,
|
||||
isHydrated,
|
||||
composerState,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ function createAgent(id: string): Agent {
|
||||
serverId: "server-1",
|
||||
id,
|
||||
provider: "claude",
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -30,7 +29,6 @@ function createAgent(id: string): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -5,14 +5,6 @@ export interface AgentScreenAgent {
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
lastError?: string | null;
|
||||
terminalExit?: {
|
||||
command: string;
|
||||
message: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
outputLines: string[];
|
||||
} | null;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
cwd?: string;
|
||||
|
||||
@@ -65,7 +65,6 @@ export function useAggregatedAgents(options?: {
|
||||
serverId,
|
||||
serverLabel,
|
||||
title: agent.title ?? null,
|
||||
terminal: agent.terminal,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -8,7 +8,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
serverId: "server-1",
|
||||
id: input?.id ?? "agent-1",
|
||||
provider: input?.provider ?? "codex",
|
||||
terminal: input?.terminal ?? false,
|
||||
status: input?.status ?? "idle",
|
||||
createdAt: input?.createdAt ?? timestamp,
|
||||
updatedAt: input?.updatedAt ?? timestamp,
|
||||
@@ -21,7 +20,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: input?.currentModeId ?? null,
|
||||
availableModes: input?.availableModes ?? [],
|
||||
|
||||
@@ -19,7 +19,6 @@ function toAggregatedAgent(params: {
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
terminal: source.terminal,
|
||||
status: source.status,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user