mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 22:02:34 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad67c7030c | ||
|
|
2250148e3a | ||
|
|
f75dff2322 | ||
|
|
2d8ff6f1cf | ||
|
|
53d824a320 | ||
|
|
fa1f01ebbc | ||
|
|
d4841b68ff | ||
|
|
ac085d50c6 | ||
|
|
1c960261ac | ||
|
|
7838b534a8 | ||
|
|
ff0171cf7b | ||
|
|
77aff0f4fb | ||
|
|
708f9260b1 | ||
|
|
e34aea206e | ||
|
|
49784fac15 | ||
|
|
4aab00f267 | ||
|
|
de8535c38b | ||
|
|
d67a6f5f6c | ||
|
|
78f7796eb4 | ||
|
|
9400a49af6 | ||
|
|
e9612df862 | ||
|
|
8dd4da6ee0 | ||
|
|
3998ea11d9 | ||
|
|
ba54575ab7 | ||
|
|
3e59adb4dc | ||
|
|
640b456915 | ||
|
|
dd5e8f95b1 | ||
|
|
59661f2bb8 | ||
|
|
06fae60809 | ||
|
|
95575ad05a | ||
|
|
7ea9286aa4 | ||
|
|
07125d2c02 | ||
|
|
0c38749c38 | ||
|
|
ecec33265e | ||
|
|
22463bb7e5 | ||
|
|
c56638ea8c | ||
|
|
185ab3f300 | ||
|
|
1694828275 | ||
|
|
74a377ff6a | ||
|
|
63923fdd07 | ||
|
|
11e76f6655 | ||
|
|
4a3e6dea63 | ||
|
|
f35d860725 | ||
|
|
95f4eb7577 | ||
|
|
fcd81f591e | ||
|
|
b0fe4efc8c | ||
|
|
f2f93087ed | ||
|
|
73a5e6c63e | ||
|
|
fa53ba8f93 | ||
|
|
375e9b3e53 | ||
|
|
adf80a9fe0 |
@@ -20,7 +20,50 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
ensure-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- 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
|
||||
|
||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--notes ""
|
||||
--draft
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
|
||||
fi
|
||||
|
||||
publish-android-apk:
|
||||
needs: [ensure-release]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
@@ -36,32 +79,6 @@ jobs:
|
||||
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
|
||||
|
||||
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"
|
||||
--notes ""
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -129,4 +146,5 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
|
||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||
else
|
||||
prerelease_flag=""
|
||||
@@ -86,8 +86,10 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--notes "" \
|
||||
--draft \
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
|
||||
}
|
||||
fi
|
||||
|
||||
@@ -191,7 +193,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -299,7 +302,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -404,7 +408,8 @@ jobs:
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
@@ -424,7 +429,7 @@ jobs:
|
||||
|
||||
finalize-rollout:
|
||||
needs: [publish-macos, publish-linux, publish-windows]
|
||||
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -480,7 +485,7 @@ jobs:
|
||||
name: windows-manifest
|
||||
path: release-manifests/windows-manifest
|
||||
|
||||
- name: Assemble and upload stamped manifests
|
||||
- name: Assemble manifests and publish release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -491,6 +496,33 @@ jobs:
|
||||
manifests_dir="$PWD/final"
|
||||
mkdir -p "$manifests_dir"
|
||||
|
||||
expected_manifests=(
|
||||
"${RELEASE_CHANNEL}-mac.yml"
|
||||
"${RELEASE_CHANNEL}-linux.yml"
|
||||
"${RELEASE_CHANNEL}.yml"
|
||||
)
|
||||
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
|
||||
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
|
||||
for manifest_name in "${expected_manifests[@]}"; do
|
||||
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
|
||||
case "$manifest_name" in
|
||||
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
|
||||
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
|
||||
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
|
||||
esac
|
||||
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
|
||||
gh release download "$release_lookup" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "$manifest_name" \
|
||||
--dir "$manifests_dir"
|
||||
else
|
||||
gh release delete-asset "$release_lookup" "$manifest_name" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--yes
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||
node ../scripts/merge-mac-manifest.mjs \
|
||||
@@ -515,6 +547,7 @@ jobs:
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||
# shellcheck disable=SC2016
|
||||
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
@@ -536,4 +569,28 @@ jobs:
|
||||
}
|
||||
}
|
||||
' "${files[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
failed_builds=()
|
||||
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
|
||||
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
|
||||
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
|
||||
if (( ${#failed_builds[@]} > 0 )); then
|
||||
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_manifests=()
|
||||
for manifest_name in "${expected_manifests[@]}"; do
|
||||
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
|
||||
done
|
||||
if (( ${#missing_manifests[@]} > 0 )); then
|
||||
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
publish_args+=(--prerelease)
|
||||
fi
|
||||
gh "${publish_args[@]}"
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
create_if_missing:
|
||||
description: "Create release if missing (normally only needed for tag events)."
|
||||
description: "Create a draft release if missing (normally only needed for tag events)."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
+64
-1
@@ -1,30 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0-beta.1 - 2026-08-27
|
||||
## 0.7.2 - 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it ([#3752](https://github.com/getpaseo/paseo/pull/3752) by [@mcowger](https://github.com/mcowger))
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins ([#4158](https://github.com/getpaseo/paseo/pull/4158))
|
||||
|
||||
### Improved
|
||||
|
||||
- Reduced JS stalls while watching a streaming agent on mobile ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling ([#4174](https://github.com/getpaseo/paseo/pull/4174))
|
||||
- Fixed mobile sidebar and explorer panels losing their settled position when a JS stall crossed the animation ([#4190](https://github.com/getpaseo/paseo/pull/4190))
|
||||
- Fixed a single oversized assistant message crashing the timeline by capping rendered content at 32,000 characters ([#4166](https://github.com/getpaseo/paseo/pull/4166))
|
||||
- Fixed Codex rewind failing on paginated threads with Codex 0.151 and later ([#4119](https://github.com/getpaseo/paseo/pull/4119))
|
||||
- Fixed Escape closing an image preview also interrupting the running agent ([#4161](https://github.com/getpaseo/paseo/pull/4161))
|
||||
- Fixed daemon stop and restart on Windows skipping graceful shutdown and orphaning agent processes ([#4168](https://github.com/getpaseo/paseo/pull/4168))
|
||||
- Fixed OMP startup timing out at 10 seconds regardless of the configured RPC timeout ([#4143](https://github.com/getpaseo/paseo/pull/4143) by [@Juns-g](https://github.com/Juns-g))
|
||||
- Fixed completed turns without a visible prompt showing no completion time ([#4170](https://github.com/getpaseo/paseo/pull/4170))
|
||||
- Fixed Explorer renames replacing an existing file when file identity metadata is unavailable ([#4171](https://github.com/getpaseo/paseo/pull/4171))
|
||||
|
||||
## 0.7.1 - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5.1 with a 1M context window ([#4179](https://github.com/getpaseo/paseo/pull/4179))
|
||||
|
||||
## 0.7.0 - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view ([#3896](https://github.com/getpaseo/paseo/pull/3896))
|
||||
- Changed the default mobile content text size from 15px to 16px ([#4111](https://github.com/getpaseo/paseo/pull/4111))
|
||||
|
||||
### Added
|
||||
|
||||
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
||||
- Added plugin-defined timeline transformations and rendering ([#3940](https://github.com/getpaseo/paseo/pull/3940))
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
|
||||
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
|
||||
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
|
||||
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
|
||||
- Added readable prompts, details, and results for Paseo tool calls ([#4066](https://github.com/getpaseo/paseo/pull/4066))
|
||||
- Added zoom and pan to workspace images and assistant timeline previews ([#4032](https://github.com/getpaseo/paseo/pull/4032), [#4049](https://github.com/getpaseo/paseo/pull/4049), [#4064](https://github.com/getpaseo/paseo/pull/4064))
|
||||
- Added opening child folders directly in the preferred desktop editor ([#3615](https://github.com/getpaseo/paseo/pull/3615) by [@caikovsky](https://github.com/caikovsky))
|
||||
- Added host navigation from plugin surfaces to agents and workspaces ([#3901](https://github.com/getpaseo/paseo/pull/3901) by [@omercnet](https://github.com/omercnet))
|
||||
- Added host-rendered icons to plugin client surfaces ([#3903](https://github.com/getpaseo/paseo/pull/3903) by [@omercnet](https://github.com/omercnet))
|
||||
- Added semantic surface, border, success, and warning colors to plugin themes ([#3898](https://github.com/getpaseo/paseo/pull/3898) by [@omercnet](https://github.com/omercnet))
|
||||
- Added registered project listing to the public plugin SDK ([#3899](https://github.com/getpaseo/paseo/pull/3899) by [@omercnet](https://github.com/omercnet))
|
||||
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
|
||||
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||
- Added F-Droid release metadata and stable changelog generation ([#2501](https://github.com/getpaseo/paseo/pull/2501) by [@antonok-edm](https://github.com/antonok-edm))
|
||||
- Added Astro syntax highlighting to files, the editor, and diffs ([#3997](https://github.com/getpaseo/paseo/pull/3997))
|
||||
|
||||
### Improved
|
||||
|
||||
- Streamed assistant text at a steady visual rate instead of in arrival lumps ([#3612](https://github.com/getpaseo/paseo/pull/3612) by [@Tommypop2](https://github.com/Tommypop2))
|
||||
- Painted cached conversations before unrelated cache restoration and network loading ([#3907](https://github.com/getpaseo/paseo/pull/3907))
|
||||
- Reused one OpenCode helper across ordinary agent sessions instead of spawning one per agent ([#4009](https://github.com/getpaseo/paseo/pull/4009))
|
||||
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
|
||||
- Added manual, action-required, and warning states to GitLab and Gitea checks ([#2337](https://github.com/getpaseo/paseo/pull/2337) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Kept cold-start pull request status checks on the shared GitHub batch path ([#4025](https://github.com/getpaseo/paseo/pull/4025))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon crashing when a Claude or OMP JSONL process closes during a write ([#4048](https://github.com/getpaseo/paseo/pull/4048))
|
||||
- Fixed Android first-message submissions crashing when the keyboard restarted during composer teardown ([#4044](https://github.com/getpaseo/paseo/pull/4044))
|
||||
- Fixed long mobile drafts hiding composer controls behind the keyboard ([#4051](https://github.com/getpaseo/paseo/pull/4051))
|
||||
- Fixed Android timelines shifting when tapping selectable text ([#4090](https://github.com/getpaseo/paseo/pull/4090))
|
||||
- Fixed web and Electron composers losing focus after submission ([#4067](https://github.com/getpaseo/paseo/pull/4067))
|
||||
- Fixed Android dictation leaving Bluetooth audio in call-quality routing after capture stopped ([#4069](https://github.com/getpaseo/paseo/pull/4069))
|
||||
- Fixed dictation retries timing out after an abandoned partial transcript ([#4065](https://github.com/getpaseo/paseo/pull/4065))
|
||||
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
|
||||
- Fixed the model selector crashing in iPad desktop layouts ([#3992](https://github.com/getpaseo/paseo/pull/3992) by [@yzim](https://github.com/yzim))
|
||||
- Fixed OpenCode child-session prompts missing from provider subagent timelines ([#4055](https://github.com/getpaseo/paseo/pull/4055) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed archived agents disappearing when opened from History ([#4033](https://github.com/getpaseo/paseo/pull/4033))
|
||||
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
|
||||
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
|
||||
- Fixed Grok unified-billing accounts showing zero credits instead of weekly usage ([#4029](https://github.com/getpaseo/paseo/pull/4029) by [@Lite-G](https://github.com/Lite-G), [@claude](https://github.com/claude))
|
||||
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
|
||||
- Fixed slow Pi and OMP startup requests timing out after 30 seconds ([#4008](https://github.com/getpaseo/paseo/pull/4008))
|
||||
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
|
||||
- Fixed Android system navigation covering the Commits controls ([#4005](https://github.com/getpaseo/paseo/pull/4005))
|
||||
- Fixed new agent work appearing as reopened tasks ([#4068](https://github.com/getpaseo/paseo/pull/4068))
|
||||
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed rewinding long conversations replaying the complete rebuilt timeline ([#3642](https://github.com/getpaseo/paseo/pull/3642))
|
||||
- Fixed rewound prompts remaining blank in the mounted iOS composer ([#3946](https://github.com/getpaseo/paseo/pull/3946))
|
||||
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||
|
||||
@@ -48,7 +48,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/source-editor.md](docs/source-editor.md) | Shared CodeMirror runtime, native bridge boundary, line-ending preservation, and embedded bundle workflow |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
|
||||
@@ -50,6 +50,8 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -31,6 +31,15 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
||||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
||||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||||
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||||
silently stops.
|
||||
|
||||
## Measuring
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Source editing uses one CodeMirror runtime on web and in an offline native WebView. Its ownership and bridge constraints are documented in [docs/source-editor.md](source-editor.md).
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
@@ -356,13 +355,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
||||
|
||||
| State | Key builder / store | Source |
|
||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
|
||||
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
|
||||
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
|
||||
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
|
||||
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
|
||||
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
|
||||
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
|
||||
|
||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
|
||||
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
|
||||
|
||||
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
|
||||
|
||||
|
||||
@@ -404,7 +404,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui`
|
||||
}
|
||||
```
|
||||
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. `params.rpcTimeoutMs` overrides the 60-second OMP control-plane RPC deadline.
|
||||
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
|
||||
+20
-19
@@ -439,25 +439,26 @@ workspace together with its owning project.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| Field | Type | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||
|
||||
|
||||
+4
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 16px on native and 15px on web and desktop. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
@@ -120,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
@@ -248,6 +250,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||
|
||||
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
+10
-6
@@ -17,17 +17,21 @@ The daemon persists a relationship ID and private connection credential before e
|
||||
relationship is independent of its current transport, so a future transport can replace the direct
|
||||
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||
|
||||
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
|
||||
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
|
||||
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
|
||||
GitHub, Slack, Discord, Linear, and other integrations create workspaces and run agents. Grant it
|
||||
during interactive login or later with `paseo hub permissions grant hub.execute`. Relationships
|
||||
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
|
||||
manage their own relationship or permissions.
|
||||
|
||||
## Session grants and execution ownership
|
||||
|
||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
||||
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||
connection receives `hub.execute`. The current enrollment and persistence format still carries
|
||||
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
|
||||
request returns the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
connection receives its persisted permissions. Legacy relationship files carrying
|
||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
||||
|
||||
The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
|
||||
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||
|
||||
+21
-10
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
|
||||
- the active gesture's starting revision
|
||||
- the last settled target
|
||||
|
||||
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
||||
React owns pointer events and accessibility.
|
||||
|
||||
## Why one position
|
||||
|
||||
@@ -55,8 +57,10 @@ while that revision still owns the gesture.
|
||||
|
||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
||||
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||
its target and revision still match the canonical command.
|
||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
||||
the same target.
|
||||
|
||||
Manual gesture arbitration has two phases:
|
||||
|
||||
@@ -85,8 +89,14 @@ definition, no longer eligible to begin.
|
||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
||||
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||
has caused native crashes.
|
||||
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||
prop commit from resurrecting a closed overlay.
|
||||
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
||||
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
|
||||
animated props back to React through a collector that forgets entries older than two seconds. A JS
|
||||
stall across a panel settle loses the final position, and the next React commit reverts the overlay
|
||||
to its last synced props: a backdrop over the workspace while the store says closed. Upstream fixed
|
||||
the collector in 4.4.3, which needs React Native 0.83.
|
||||
- Gesture start and progress must not update React state. The retained overlay is already mounted and
|
||||
offscreen; only the shared position and its derived native styles move during a drag.
|
||||
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||
@@ -98,8 +108,9 @@ definition, no longer eligible to begin.
|
||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
||||
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
||||
canonical target's anchor is the settlement signal; animation duration and completion callbacks
|
||||
do not own activity. Cancellation publishes nothing.
|
||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
||||
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
|
||||
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||
@@ -107,5 +118,5 @@ definition, no longer eligible to begin.
|
||||
## Tests
|
||||
|
||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||
sequence there whenever ownership or ordering changes.
|
||||
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
||||
a sequence there whenever ownership or ordering changes.
|
||||
|
||||
+1
-1
@@ -56,4 +56,4 @@ The Hub authenticates as a service principal. Its locally selected grants decide
|
||||
|
||||
Hub user and role identifiers remain opaque external subjects. The Hub may create and revoke linked daemon principals when granted `access.manage`; the daemon does not interpret accounts, organizations, or roles.
|
||||
|
||||
The current Hub enrollment protocol still exchanges `hub.execution.*`. The daemon translates that legacy value to `hub.execute` once at the connection boundary. Remove the translation when Hub enrollment exchanges semantic permissions.
|
||||
Hub enrollment and permission updates exchange these semantic permissions directly. Legacy persisted Hub relationships that contain `hub.execution.*` migrate once to `hub.execute` when the daemon loads them; new relationships never persist or emit transport scopes as authority.
|
||||
|
||||
+178
-71
@@ -1,9 +1,10 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
|
||||
## Install a directory source
|
||||
|
||||
@@ -40,22 +41,24 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
|
||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
|
||||
The directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
|
||||
directories, and local typechecking support. At least one entry is required.
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
paseo-plugin.d.ts
|
||||
package.json
|
||||
tsconfig.json
|
||||
index.client.tsx
|
||||
index.server.ts
|
||||
client/greeting.tsx
|
||||
server/greeting.ts
|
||||
shared/greeting.ts
|
||||
```
|
||||
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
||||
runtime modules, so consumers do not install these packages when adding the plugin.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -84,44 +87,71 @@ directory always wins over shorthand resolution.
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo --path plugins/review
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. `update` checks out a new version, validates its manifest, compiles both bundles, and starts
|
||||
it before changing the configured path. A compilation or startup failure restores the running
|
||||
version. Removing a Git source deletes Paseo's managed checkout.
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
Git installation does not run a package manager or install script. A distributable source plugin
|
||||
uses Paseo's host-provided modules or commits any other bundled source it needs. Native React Native
|
||||
dependencies work only when the Paseo app already ships the native module.
|
||||
### Declare Git preparation
|
||||
|
||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
||||
that Paseo does not provide, generate source or assets, or perform another required preparation
|
||||
step:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "review",
|
||||
"build": [
|
||||
["npm", "ci"],
|
||||
["npm", "run", "build"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`build` is an optional list of argv arrays. Each array must contain at least one non-empty string;
|
||||
shell command strings are rejected. Paseo starts the executable directly, without a shell, from the
|
||||
plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.
|
||||
|
||||
On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared
|
||||
commands, then validates, compiles, and activates the candidate. It logs each argv command and its
|
||||
output in the daemon log. If a command fails, the error includes its output, Paseo discards the
|
||||
candidate, and the existing installed and running version stays untouched. On a remote daemon, all
|
||||
of this happens on the remote daemon host.
|
||||
|
||||
Server contributions can write to stdout and stderr with normal Node logging. Paseo adds `[paseo]`
|
||||
entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are
|
||||
recorded as stderr entries before a subprocess exists. Inspect the recent in-memory
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||
tail from the host plugin settings or with `paseo plugin logs <id>`. Git preparation commands are
|
||||
recorded in `$PASEO_HOME/daemon.log` before a plugin exists, rather than the plugin log tail. Reload, disable, and process
|
||||
failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but
|
||||
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||
log credentials or tokens.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
|
||||
code lives behind filename boundaries:
|
||||
Default export one contribution function from each runtime entry. Keep the entries to registration
|
||||
wiring. Runtime code lives behind directory boundaries:
|
||||
|
||||
| Suffix | Owns |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
| Path | Owns |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||
| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `shared/` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
@@ -130,28 +160,45 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text`
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
|
||||
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
|
||||
server bundle.
|
||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
||||
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
|
||||
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
|
||||
Native calls such as `StyleSheet.create` belong in `client/`.
|
||||
|
||||
The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference lib="dom" />`, so
|
||||
browser globals are not available across the plugin. Put sanctioned web-only APIs in
|
||||
`client/web.ts`, declare only the globals that module uses, gate each export with
|
||||
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
|
||||
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { Greeting } from "./greeting.client";
|
||||
import { createGreeting } from "./greeting.server";
|
||||
import { greetRpc } from "./greeting.shared";
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greetRpc } from "./shared/greeting";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, createGreeting);
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(greetRpc, createGreeting);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { Greeting } from "./client/greeting";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addSurface("main", Greeting);
|
||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -186,13 +233,13 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Plugin code cannot address another host.
|
||||
|
||||
Workspace panels and Command Center items remain client contributions. The daemon transports their
|
||||
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
|
||||
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
|
||||
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
|
||||
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
|
||||
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
|
||||
app state or a memoized selection. Panels use one persisted
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
|
||||
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
|
||||
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
|
||||
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
|
||||
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
|
||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||
@@ -208,10 +255,10 @@ Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||
client state without mounting a panel or surface.
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
@@ -242,8 +289,8 @@ export function contributeClient(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
@@ -252,20 +299,67 @@ the host, or unloading the app tears down the contribution.
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
built-in projection stay unchanged. The app transforms fetched projected history before building
|
||||
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
|
||||
collapsed before the transformer replaces anything.
|
||||
built-in projection stay unchanged. The app transforms each source item while building the render
|
||||
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
|
||||
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
|
||||
streaming updates preserve mounted component identity.
|
||||
|
||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
||||
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
|
||||
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
|
||||
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
|
||||
schema, and mounts the component inside the normal plugin runtime and error boundary.
|
||||
runtime plugin ID, preserves the source timeline cursor and identity, validates renderer data with
|
||||
its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An
|
||||
optional output `id` distinguishes several stable replacements from the same source item; its output
|
||||
index is the default.
|
||||
|
||||
Transformers run synchronously and must be deterministic. When several transformers match, the
|
||||
first one that returns a result owns that source item. Plugin and registration ordering is stable.
|
||||
See `plugin-examples/timeline-items` for the complete contract.
|
||||
|
||||
A plugin subprocess can also append a canonical plugin row from a server handler:
|
||||
|
||||
```ts
|
||||
await paseo.agents.ref(agentId).timeline.append({
|
||||
type: "plugin",
|
||||
id: "review",
|
||||
kind: "review-result",
|
||||
version: 1,
|
||||
data: { status: "ready" },
|
||||
});
|
||||
```
|
||||
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
client.addSlashCommand({
|
||||
name: "review",
|
||||
description: "Run the review bot",
|
||||
argumentHint: "[scope]",
|
||||
context: "agent",
|
||||
async onSubmit({ args, agent, rpc }) {
|
||||
await rpc(startReview, { agentId: agent.id, scope: args });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Paseo owns the autocomplete row, input clearing, and error toast. It never sends the command text to
|
||||
the agent. Built-in client commands win name and alias collisions, plugin commands win
|
||||
provider-command collisions, and the first plugin in stable catalog order wins collisions between
|
||||
plugins. Plugin slash commands do not run when the composer has attachments.
|
||||
|
||||
## Contribute composer attachments
|
||||
|
||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||
@@ -273,13 +367,24 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { search } from "./issues.server";
|
||||
import { issues, searchIssues } from "./issues.shared";
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { search } from "./server/issues";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, search);
|
||||
plugin.addAttachmentSource(issues);
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(searchIssues, search);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// index.client.tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { issues } from "./shared/issues";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addAttachmentSource(issues);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
@@ -298,8 +403,8 @@ contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
||||
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||
for the runtime-patching rules the appearance settings share.
|
||||
|
||||
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||
`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it
|
||||
cannot evaluate that entry. Daemons advertise
|
||||
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||
a host without it contributes no themes.
|
||||
|
||||
@@ -312,6 +417,8 @@ stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||
`plugin-examples/catppuccin` for a theme.
|
||||
|
||||
+3
-3
@@ -67,7 +67,7 @@ Pi model records expose input capabilities through `model.input`. Only send raw
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi and OMP control-plane RPCs wait 60 seconds by default. Override the provider's `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs a different deadline. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results.
|
||||
Pi control-plane RPCs wait 60 seconds by default. Override `params.rpcTimeoutMs` when extension or MCP startup on a slow host needs more time. Timeout errors name the pending RPC phase and report both elapsed time and the configured deadline. This setting does not govern long-running Pi compaction or Pi extension UI results. See [OMP profiles and Pi-compatible forks](custom-providers.md#omp-profiles-and-pi-compatible-forks) for OMP startup and RPC deadlines.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
@@ -85,9 +85,9 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
+30
-7
@@ -112,7 +112,7 @@ npm run release:patch
|
||||
npm run release:minor
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
|
||||
|
||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
||||
version for every published package. This changes dist-tags only; do not
|
||||
@@ -180,10 +180,10 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
|
||||
|
||||
Desktop release builds now publish in two phases:
|
||||
|
||||
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
|
||||
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
|
||||
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
|
||||
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
|
||||
|
||||
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
|
||||
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
|
||||
|
||||
### Default behavior
|
||||
|
||||
@@ -375,7 +375,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -404,6 +404,13 @@ 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.
|
||||
|
||||
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
|
||||
uploads manifests from successful platforms before it fails. A later
|
||||
single-platform retry reuses those manifests, stamps the complete set with one
|
||||
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
|
||||
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
|
||||
the same path against the existing draft.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||
image.
|
||||
@@ -433,6 +440,22 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `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
|
||||
|
||||
If you decide to publish a release without working desktop builds, inspect its
|
||||
assets first, then publish it manually:
|
||||
|
||||
```bash
|
||||
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
|
||||
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
|
||||
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
|
||||
|
||||
# Keep a beta marked as a prerelease:
|
||||
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
|
||||
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
|
||||
```
|
||||
|
||||
This bypasses the updater-manifest guarantee. Use it only when the release is
|
||||
intentionally unavailable to desktop updater clients.
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
@@ -585,7 +608,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
@@ -608,7 +631,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
|
||||
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Source editor
|
||||
|
||||
The source editor has one CodeMirror runtime and two hosts:
|
||||
|
||||
- Web mounts the runtime directly into the page.
|
||||
- iOS and Android mount the same runtime from a generated, offline HTML document in a WebView.
|
||||
|
||||
Keep editor mechanics in `packages/app/src/source-editor/`. File loading, conflict handling, and
|
||||
persistence belong to the file pane's `FileEditorModel`. The source editor receives a document and
|
||||
emits edits; it does not know about workspaces or daemon RPCs.
|
||||
|
||||
## Native bridge
|
||||
|
||||
The native bridge sends compact change ranges in normalized LF coordinates. The host-side document
|
||||
mirror validates and applies them, then restores the file's original line separator before calling
|
||||
the file model. Full document replacements cross the bridge only for mount and external changes.
|
||||
|
||||
Every message carries an editor key. Ignore messages from an old key after remounting or switching
|
||||
files. Keep the bridge protocol private to this module; React components outside it use
|
||||
`SourceEditorProps`.
|
||||
|
||||
Do not expose raw CodeMirror extensions as the cross-platform API. Add named capabilities to the
|
||||
source editor contract, then implement them once in the shared runtime or explicitly in each host.
|
||||
|
||||
## Embedded bundle
|
||||
|
||||
After changing the CodeMirror runtime, bridge protocol, or WebView entry point, run:
|
||||
|
||||
```bash
|
||||
npm --prefix packages/app run build:source-editor-webview
|
||||
```
|
||||
|
||||
The generated `webview-html.ts` is committed so native builds do not need a runtime bundler or
|
||||
network access. `build:source-editor-webview:check` fails when the committed artifact is stale, and
|
||||
EAS post-install rebuilds all embedded WebViews.
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Changed
|
||||
- Changed the project license to Apache-2.0
|
||||
- Changed Cmd/Ctrl+E to toggle Explorer visibility without selecting a view
|
||||
- Changed the default mobile content text size from 15px to 16px
|
||||
|
||||
Added
|
||||
- Added plugin installation and updates directly from Git repositories
|
||||
- Added plugin-defined timeline transformations and rendering
|
||||
- Added SSH connections to existing remote daemons from Desktop and CLI
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Added
|
||||
- Added Claude Fable 5.1 with a 1M context window
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,11 @@
|
||||
Added
|
||||
- Added active-turn steering for pi agents so new messages reach the running turn without interrupting it
|
||||
- Added declared build commands and monorepo source paths for Git-hosted plugins
|
||||
|
||||
Improved
|
||||
- Reduced JS stalls while watching a streaming agent on mobile
|
||||
|
||||
Fixed
|
||||
- Fixed large and many-file diffs stalling or crashing while opening and scrolling
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
|
||||
|
||||
Generated
+27
-74
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37363,12 +37363,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/server": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/server": "0.7.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37615,10 +37615,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37872,7 +37872,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38797,13 +38797,13 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38812,15 +38812,15 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/highlight": "0.7.0-beta.1",
|
||||
"@getpaseo/plugin": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"@getpaseo/relay": "0.7.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39106,52 +39105,6 @@
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/parser": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -39985,7 +39938,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -39,7 +39,10 @@ import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
@@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
|
||||
await target.client.waitForFinish(target.agentId, 30_000);
|
||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: target.workspaceId,
|
||||
});
|
||||
await openAgentRoute(page, target);
|
||||
await expectComposerVisible(page);
|
||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
switchAwayAndBackWithoutImageInstability,
|
||||
userPagesUntilAssistantImageRenders,
|
||||
} from "../support/helpers/assistant-images";
|
||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||
@@ -84,8 +85,15 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||
.toBe(Math.round(initialBox!.width));
|
||||
|
||||
await canvas.click({ position: { x: 4, y: 4 } });
|
||||
await workspace.client.sendAgentMessage(
|
||||
imageAgent.id,
|
||||
"Stay running while the assistant image lightbox is closed.",
|
||||
);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(lightboxImage).toHaveCount(0);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getUtf8ByteLength } from "../../src/components/assistant-message-render-limit";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test("caps a newline-heavy assistant message before rendering markdown", async ({ page }) => {
|
||||
const hiddenTail = "HIDDEN_OVERSIZED_MESSAGE_TAIL";
|
||||
const response = `${"x\n".repeat(16_000)}${hiddenTail}`;
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-message-render-limit-",
|
||||
title: "Assistant message render limit",
|
||||
initialPrompt: "Render the configured oversized response.",
|
||||
featureValues: { mockAssistantResponse: response },
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(assistantMessage).not.toContainText(hiddenTail);
|
||||
|
||||
const notice = assistantMessage.getByTestId("assistant-message-capped-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveText(
|
||||
`This message was capped (${getUtf8ByteLength(response)} bytes).`,
|
||||
);
|
||||
await expect(notice).toHaveCSS("font-style", "italic");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
@@ -37,6 +37,10 @@ function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||
}
|
||||
|
||||
function diffHeaderForPath(panel: Locator, filePath: string): Locator {
|
||||
return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/);
|
||||
}
|
||||
|
||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
@@ -261,12 +265,12 @@ test.afterEach(async () => {
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
@@ -302,17 +306,33 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangedFileAndExpectFocused(page, "committed/50-target.ts");
|
||||
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
@@ -322,9 +342,8 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
@@ -378,49 +397,109 @@ test("every interactive file header has the same hover feedback", async ({ page
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const normalBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element.parentElement!).backgroundColor,
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||
)
|
||||
.toBe("0px");
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
const hoverBackground = await first.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(hoverBackground).not.toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||
)
|
||||
.toBe(normalBackground);
|
||||
const [sharedBorder, secondTopBorderWidth] = await Promise.all([
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor),
|
||||
second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||
]);
|
||||
expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(secondTopBorderWidth).toBe("0px");
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect
|
||||
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||
(element as HTMLCanvasElement).toDataURL(),
|
||||
);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||
beforeImage,
|
||||
);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return [14, 16].map(
|
||||
(y) =>
|
||||
document
|
||||
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||
);
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(() =>
|
||||
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||
)
|
||||
.toBe(normalBackground);
|
||||
await expect
|
||||
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(hoverBackground);
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
@@ -475,7 +554,8 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
@@ -485,9 +565,17 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
await expect(diffNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
@@ -586,9 +674,10 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const renamedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-renamed.ts" });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
@@ -615,9 +704,10 @@ test("discarding an untracked file removes it from the working tree", async ({ p
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const untrackedToggle = page
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.filter({ hasText: "zz-untracked.txt" });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
|
||||
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
|
||||
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||
@@ -676,8 +766,12 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
@@ -712,16 +806,18 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
@@ -923,6 +1019,16 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -980,6 +1086,29 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1023,6 +1152,33 @@ test("canvas diff copies a dragged character selection without opening a review"
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-selection").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-line").click();
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithTwoSelectionDiffs();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await rightClickFirstChangedLine(page, 1);
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1363,6 +1519,46 @@ async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithScrollableComparisons(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-comparison-focus-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = true;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const longFile = Array.from(
|
||||
{ length: 120 },
|
||||
(_, index) => `export const line${index} = ${index};`,
|
||||
).join("\n");
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||
await writeComparisonFiles(repo.path, "committed", longFile);
|
||||
execFileSync("git", ["add", "committed"], { cwd: repo.path });
|
||||
execFileSync("git", ["commit", "-m", "Add committed comparison files"], { cwd: repo.path });
|
||||
await writeComparisonFiles(repo.path, "uncommitted", longFile);
|
||||
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create comparison workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function writeComparisonFiles(
|
||||
repoPath: string,
|
||||
directory: "committed" | "uncommitted",
|
||||
longFile: string,
|
||||
): Promise<void> {
|
||||
await mkdir(path.join(repoPath, directory), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(repoPath, directory, "00-before.ts"), `${longFile}\n`),
|
||||
writeFile(path.join(repoPath, directory, "50-target.ts"), "export const target = true;\n"),
|
||||
writeFile(path.join(repoPath, directory, "99-after.ts"), `${longFile}\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
files: [{ path: "src/selection.ts", content: "" }],
|
||||
@@ -1380,6 +1576,81 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-shift-", {
|
||||
files: [
|
||||
{ path: "src/first.ts", content: "" },
|
||||
{ path: "src/second.ts", content: "" },
|
||||
],
|
||||
});
|
||||
await Promise.all([
|
||||
writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"),
|
||||
writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"),
|
||||
]);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
),
|
||||
);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace> {
|
||||
const source = Array.from(
|
||||
{ length: 48 },
|
||||
(_, index) => `export const value${index} = ${index};`,
|
||||
).join("\n");
|
||||
const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({
|
||||
path: filePath,
|
||||
content: `${source}\n`,
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files });
|
||||
const changedSource = Array.from(
|
||||
{ length: 48 },
|
||||
(_, index) => `export const value${index} = ${index + 100};`,
|
||||
).join("\n");
|
||||
await Promise.all(
|
||||
files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)),
|
||||
);
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
@@ -1389,11 +1660,15 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
async function openWorkspaceChangesSurface(
|
||||
page: Page,
|
||||
workspace: DirtyWorkspace,
|
||||
timeout?: number,
|
||||
): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
await openChangesPanel(page, timeout);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -1402,7 +1677,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
@@ -1424,6 +1699,54 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function selectChangesComparison(
|
||||
page: Page,
|
||||
comparison: "Committed" | "Uncommitted",
|
||||
): Promise<void> {
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await tree.getByTestId("changes-diff-status-trigger").click();
|
||||
await page.getByTestId(`changes-diff-mode-${comparison.toLowerCase()}`).click();
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText(comparison);
|
||||
}
|
||||
|
||||
async function expectWorkingComparisonFiles(
|
||||
page: Page,
|
||||
comparison: "committed" | "uncommitted",
|
||||
): Promise<void> {
|
||||
const tree = changesTree(page);
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const paths = ["00-before.ts", "50-target.ts", "99-after.ts"];
|
||||
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
|
||||
paths,
|
||||
);
|
||||
for (const fileName of paths) {
|
||||
await expect(diffHeaderForPath(panel, `${comparison}/${fileName}`)).toBeAttached();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChangedFileAndExpectFocused(page: Page, filePath: string): Promise<void> {
|
||||
const tree = changesTree(page);
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const scroller = panel.getByTestId("git-diff-scroll");
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await tree.getByText(path.basename(filePath), { exact: true }).click();
|
||||
const header = diffHeaderForPath(panel, filePath);
|
||||
await expect(header).toBeVisible();
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
|
||||
const [scrollBounds, headerBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
header.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !headerBounds)
|
||||
throw new Error("Focused diff header geometry is unavailable");
|
||||
expect(headerBounds.y - scrollBounds.y).toBeCloseTo(0, 0);
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
@@ -1490,6 +1813,20 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||
}
|
||||
|
||||
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, {
|
||||
button: "right",
|
||||
});
|
||||
}
|
||||
|
||||
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
@@ -1530,8 +1867,9 @@ async function deleteInlineReview(page: Page): Promise<void> {
|
||||
async function dragExactAddedText(
|
||||
page: Page,
|
||||
offsets: { startOffset: number; endOffset: number },
|
||||
fileIndex = 0,
|
||||
): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, metrics] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
@@ -1701,6 +2039,30 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function headerCanvasPixel(
|
||||
canvas: Locator,
|
||||
header: Locator,
|
||||
offsetY: number,
|
||||
): Promise<string> {
|
||||
const [canvasBounds, headerBounds] = await Promise.all([
|
||||
canvas.boundingBox(),
|
||||
header.boundingBox(),
|
||||
]);
|
||||
if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds");
|
||||
return canvas.evaluate(
|
||||
(element, point) => {
|
||||
const target = element as HTMLCanvasElement;
|
||||
const ratio = target.width / target.getBoundingClientRect().width;
|
||||
const context = target.getContext("2d");
|
||||
if (!context) throw new Error("Canvas header has no 2D context");
|
||||
return Array.from(
|
||||
context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data,
|
||||
).join(",");
|
||||
},
|
||||
{ x: 4, y: headerBounds.y - canvasBounds.y + offsetY },
|
||||
);
|
||||
}
|
||||
|
||||
async function readDiffTypographyGeometry(page: Page): Promise<{
|
||||
horizontalExtent: number;
|
||||
canvasPixels: string;
|
||||
|
||||
@@ -103,23 +103,15 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, content, name, stat] = await Promise.all([
|
||||
const [header, canvas] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-header-content").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-name").boundingBox(),
|
||||
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(content).not.toBeNull();
|
||||
expect(name).not.toBeNull();
|
||||
expect(stat).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
expect(header!.height).toBeCloseTo(30, 0);
|
||||
expect(content!.x).toBeCloseTo(header!.x, 0);
|
||||
expect(content!.width).toBeCloseTo(header!.width, 0);
|
||||
expect(name!.x - header!.x).toBeCloseTo(12, 0);
|
||||
expect(name!.x + name!.width).toBeLessThan(stat!.x);
|
||||
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
|
||||
expect(header!.x).toBeCloseTo(canvas!.x, 0);
|
||||
expect(header!.width).toBeCloseTo(canvas!.width, 0);
|
||||
expect(header!.y).toBeCloseTo(canvas!.y, 0);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -0");
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
|
||||
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
||||
|
||||
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
||||
const RUN_HEADER_COMPARISON = process.env.PASEO_DIFF_HEADER_COMPARISON === "1";
|
||||
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
||||
const LINE_COUNT_PER_FILE = 1_200;
|
||||
const MANY_FILE_COUNT = Number(process.env.PASEO_DIFF_MANY_FILE_COUNT ?? 2_000);
|
||||
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
|
||||
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
@@ -21,8 +23,10 @@ const POC_MEDIAN_FRAME_MS = Number(
|
||||
const POC_P95_FRAME_MS = Number(
|
||||
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
||||
);
|
||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
|
||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
|
||||
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
|
||||
const MANY_FILE_MAX_P95_FRAME_MS = 80;
|
||||
|
||||
diffPerfDescribe("Diff canvas performance", () => {
|
||||
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
||||
@@ -58,7 +62,131 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
||||
test("many small files keep canvas headers bounded and responsive", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(300_000);
|
||||
const repo = await createManyFileDiffRepository(MANY_FILE_COUNT);
|
||||
const client = await connectSeedClient();
|
||||
let cdp: CDPSession | null = null;
|
||||
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? "Failed to create many-file performance workspace");
|
||||
}
|
||||
|
||||
await configureUnwrappedUnifiedDiff(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
const openedAt = performance.now();
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page, 90_000);
|
||||
await expect(page.getByTestId("diff-file-0-toggle")).toBeVisible({ timeout: 30_000 });
|
||||
const readyMs = performance.now() - openedAt;
|
||||
const [initialShells, initialElements, paintKind] = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
.evaluate(
|
||||
(root) =>
|
||||
[
|
||||
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||
root.querySelectorAll("*").length,
|
||||
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||
] as const,
|
||||
);
|
||||
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||
const style = getComputedStyle(header);
|
||||
const bounds = header.getBoundingClientRect();
|
||||
const leaves = [];
|
||||
for (const element of header.querySelectorAll<HTMLElement>("*")) {
|
||||
if (element.children.length !== 0 || !element.textContent?.trim()) continue;
|
||||
const leafStyle = getComputedStyle(element);
|
||||
const leafBounds = element.getBoundingClientRect();
|
||||
leaves.push({
|
||||
text: element.textContent,
|
||||
x: leafBounds.x - bounds.x,
|
||||
y: leafBounds.y - bounds.y,
|
||||
width: leafBounds.width,
|
||||
height: leafBounds.height,
|
||||
font: leafStyle.font,
|
||||
lineHeight: leafStyle.lineHeight,
|
||||
color: leafStyle.color,
|
||||
});
|
||||
}
|
||||
return {
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
padding: style.padding,
|
||||
border: style.border,
|
||||
boxShadow: style.boxShadow,
|
||||
leaves,
|
||||
};
|
||||
});
|
||||
const headerCanvasMetrics = await page
|
||||
.getByTestId("git-diff-header-canvas")
|
||||
.evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Header canvas has no 2D context");
|
||||
const font = context.font;
|
||||
return {
|
||||
font,
|
||||
nameWidth: context.measureText("file-0000.ts").width,
|
||||
directoryWidth: context.measureText(" src").width,
|
||||
};
|
||||
})
|
||||
.catch(() => null);
|
||||
const capturePath = process.env.PASEO_DIFF_HEADER_CAPTURE_PATH;
|
||||
if (capturePath) {
|
||||
await writeFile(capturePath, await page.getByTestId("git-diff-canvas-root").screenshot());
|
||||
}
|
||||
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
await startRendererInstrumentation(page);
|
||||
const benchmark = await benchmarkManyFileHeaderScrolling(page);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
const heap = await collectHeapUsage(cdp);
|
||||
|
||||
await attachPerformanceReport(testInfo, {
|
||||
scenario: "many-small-files",
|
||||
sourceFiles: MANY_FILE_COUNT,
|
||||
cpuSlowdown: CPU_SLOWDOWN,
|
||||
readyMs,
|
||||
initialShells,
|
||||
initialElements,
|
||||
paintKind,
|
||||
headerDomMetrics,
|
||||
headerCanvasMetrics,
|
||||
benchmark,
|
||||
renderer,
|
||||
heap,
|
||||
});
|
||||
console.log(
|
||||
`[perf] Many-file headers: ${JSON.stringify({ readyMs, initialShells, initialElements, paintKind, headerDomMetrics, headerCanvasMetrics, benchmark, renderer, heap })}`,
|
||||
);
|
||||
if (!RUN_HEADER_COMPARISON) {
|
||||
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||
expect(Math.abs(benchmark.maximumScrollTop - benchmark.endScrollTop)).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(80);
|
||||
}
|
||||
} finally {
|
||||
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
|
||||
await cdp?.detach().catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("bounded canvases stay filled through continuous fast scrolling", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const repo = await createLargeDiffRepository();
|
||||
const client = await connectSeedClient();
|
||||
@@ -80,7 +208,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectSingleCanvasSurface(page);
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||
await startChromiumTrace(cdp);
|
||||
@@ -110,6 +238,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
const measuredCycles = scrollReports.slice(1);
|
||||
console.log(
|
||||
`[perf] Large-file canvas: ${JSON.stringify({ scrollReports, heapUsage, renderer })}`,
|
||||
);
|
||||
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
||||
MAX_MEDIAN_FRAME_MS,
|
||||
);
|
||||
@@ -122,9 +253,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
||||
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
||||
);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
|
||||
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
||||
@@ -169,7 +300,7 @@ async function measureAdditionalPerformanceScenario(
|
||||
testInfo: TestInfo,
|
||||
name: "wrapped" | "split",
|
||||
) {
|
||||
await expectSingleCanvasSurface(page);
|
||||
await expectBoundedCanvasSurfaces(page);
|
||||
await startRendererInstrumentation(page);
|
||||
const scrollReports = [];
|
||||
const heapUsage = [];
|
||||
@@ -202,9 +333,9 @@ async function measureAdditionalPerformanceScenario(
|
||||
}
|
||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
||||
const renderer = await readRendererInstrumentation(page);
|
||||
expect(renderer.addedElements).toBe(0);
|
||||
expect(renderer.removedElements).toBe(0);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
||||
return {
|
||||
scrollReports,
|
||||
heapUsage,
|
||||
@@ -251,6 +382,20 @@ async function createLargeDiffRepository() {
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function createManyFileDiffRepository(fileCount: number) {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("diff-perf-many-files-", { files });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
),
|
||||
);
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
@@ -289,8 +434,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
return performance.now() - startedAt;
|
||||
}
|
||||
|
||||
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
@@ -377,6 +522,92 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
medianFrameMs: number;
|
||||
p95FrameMs: number;
|
||||
maximumFrameMs: number;
|
||||
maximumShells: number;
|
||||
blankHeaderFrames: number;
|
||||
endScrollTop: number;
|
||||
maximumScrollTop: number;
|
||||
}> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
'[data-testid="git-diff-header-canvas"]',
|
||||
);
|
||||
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
const frameDurations: number[] = [];
|
||||
let maximumShells = 0;
|
||||
let blankHeaderFrames = 0;
|
||||
const sampleHeader = () => {
|
||||
maximumShells = Math.max(
|
||||
maximumShells,
|
||||
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||
);
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d")!;
|
||||
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||
if (
|
||||
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
if (
|
||||
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
};
|
||||
|
||||
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const sweepStart = Math.min(
|
||||
maximumScrollTop,
|
||||
Math.max(0, maximumScrollTop * progress - scroll.clientHeight),
|
||||
);
|
||||
scroll.scrollTop = sweepStart;
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
let previousFrame = performance.now();
|
||||
for (let step = 1; step <= 24; step += 1) {
|
||||
scroll.scrollTop = Math.min(
|
||||
maximumScrollTop,
|
||||
sweepStart + (scroll.clientHeight * step) / 24,
|
||||
);
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const now = performance.now();
|
||||
frameDurations.push(now - previousFrame);
|
||||
previousFrame = now;
|
||||
sampleHeader();
|
||||
}
|
||||
}
|
||||
|
||||
scroll.scrollTop = maximumScrollTop;
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
sampleHeader();
|
||||
|
||||
const ordered = [...frameDurations].sort((left, right) => left - right);
|
||||
const percentile = (fraction: number) =>
|
||||
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
|
||||
return {
|
||||
medianFrameMs: percentile(0.5),
|
||||
p95FrameMs: percentile(0.95),
|
||||
maximumFrameMs: Math.max(...frameDurations),
|
||||
maximumShells,
|
||||
blankHeaderFrames,
|
||||
endScrollTop: scroll.scrollTop,
|
||||
maximumScrollTop,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedCanvasFrame {
|
||||
scrollTop: number;
|
||||
image: Buffer;
|
||||
@@ -454,23 +685,35 @@ async function expectContinuousFramesMatchStableRender(
|
||||
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
||||
const firstOffset = await setHorizontalOffset(first, 240);
|
||||
const secondOffset = await setHorizontalOffset(second, 120);
|
||||
await setVerticalOffset(scroller, 0);
|
||||
await expect(first).toBeAttached();
|
||||
const firstOffset = await setHorizontalOffset(first, 40);
|
||||
await setVerticalOffset(scroller, "end");
|
||||
await expect(second).toBeAttached();
|
||||
const secondOffset = await setHorizontalOffset(second, 20);
|
||||
expect(firstOffset).not.toBe(secondOffset);
|
||||
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
await setVerticalOffset(scroller, 0);
|
||||
await expect(first).toBeAttached();
|
||||
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
||||
await setVerticalOffset(scroller, "end");
|
||||
await expect(second).toBeAttached();
|
||||
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
||||
}
|
||||
|
||||
async function setVerticalOffset(locator: Locator, offset: number | "end") {
|
||||
await locator.evaluate(async (element, nextOffset) => {
|
||||
element.scrollTop =
|
||||
nextOffset === "end" ? element.scrollHeight - element.clientHeight : nextOffset;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
}, offset);
|
||||
}
|
||||
|
||||
async function setHorizontalOffset(locator: Locator, offset: number) {
|
||||
return locator.evaluate(async (element, nextOffset) => {
|
||||
element.scrollLeft = nextOffset;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
startWorkspaceScriptFromMenu,
|
||||
} from "../support/helpers/workspace-setup";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const evidenceDir = "/tmp/fork-setup-gate";
|
||||
|
||||
test("fork workspace setup stays blocked until the user runs it", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
mkdirSync(evidenceDir, { recursive: true });
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("fork-setup-gate-", {
|
||||
withRemote: true,
|
||||
originUrl: "https://github.com/paseo-e2e/local-fixture.git",
|
||||
branches: ["pr-branch-2"],
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["node -e \"setTimeout(() => console.log('setup complete'), 1500)\""],
|
||||
},
|
||||
scripts: { dev: { command: 'node -e "setTimeout(() => {}, 30000)"' } },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const localRemote = path.join(repo.path, "remote.git");
|
||||
execFileSync("git", ["update-ref", "refs/pull/2/head", "refs/heads/pr-branch-2"], {
|
||||
cwd: localRemote,
|
||||
});
|
||||
execFileSync(
|
||||
"git",
|
||||
["config", `url.${localRemote}.insteadOf`, "git@github.com:fork-owner/local-fixture.git"],
|
||||
{ cwd: repo.path },
|
||||
);
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"config",
|
||||
"--add",
|
||||
`url.${localRemote}.insteadOf`,
|
||||
"https://github.com/paseo-e2e/local-fixture.git",
|
||||
],
|
||||
{ cwd: repo.path },
|
||||
);
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const result = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
cwd: repo.path,
|
||||
action: "checkout",
|
||||
checkoutSource: { kind: "change_request", forge: "github", number: 2 },
|
||||
},
|
||||
});
|
||||
if (!result.workspace || result.error)
|
||||
throw new Error(result.error ?? "Workspace creation failed");
|
||||
expect(result.setupSkippedReason).toContain("Scripts are blocked for PR #2");
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), result.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("the blocked setup tab opens with the fork warning", async () => {
|
||||
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("alert")).toContainText("fork-owner/local-fixture");
|
||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "blocked-fork-setup.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("starting a script explains why it is blocked", async () => {
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
await startWorkspaceScriptFromMenu(page, "dev");
|
||||
await expect(page.getByText(/Scripts are blocked for PR #2/)).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "blocked-script-error.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("running setup replaces the warning with normal progress", async () => {
|
||||
await page.getByRole("button", { name: "Run setup" }).click();
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(/Running|Completed/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, "fork-setup-running.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText("Completed", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { test, type Page } from "../support/fixtures";
|
||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
type OpenedProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
const SCREENSHOT_DIRECTORY = path.join(
|
||||
process.env.HOME ?? tmpdir(),
|
||||
".paseo/plans/import-session-ux",
|
||||
);
|
||||
const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-"));
|
||||
const brokenProvider = "broken-acp";
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
[brokenProvider]: {
|
||||
extends: "acp",
|
||||
label: "Broken ACP",
|
||||
command: ["missing-agent-command", "acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory },
|
||||
});
|
||||
|
||||
interface ImportFlowScenario {
|
||||
project: OpenedProject;
|
||||
reuseTarget: OpenedProject;
|
||||
projectName: string;
|
||||
projectRoot: string;
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
repoCleanup(): Promise<void>;
|
||||
unrelatedCleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
let scenario: ImportFlowScenario;
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await mkdir(SCREENSHOT_DIRECTORY, { recursive: true });
|
||||
const repo = await createTempGitRepo("isf-", {
|
||||
originUrl: "https://github.com/paseo-e2e/import-session-fixture.git",
|
||||
});
|
||||
const unrelated = await createTempDirectory("isf-other-");
|
||||
const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix");
|
||||
await mkdir(path.dirname(worktreeDirectory), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!project.workspace) {
|
||||
throw new Error(project.error ?? "Failed to create the import-session fixture workspace");
|
||||
}
|
||||
const projects = await client.listProjects();
|
||||
const projectDescriptor = projects.projects.find(
|
||||
(candidate) => candidate.projectId === project.workspace?.projectId,
|
||||
);
|
||||
if (!projectDescriptor?.projectKey) {
|
||||
throw new Error("Fixture workspace has no project key");
|
||||
}
|
||||
|
||||
const openedProject: OpenedProject = {
|
||||
workspaceId: project.workspace.id,
|
||||
projectId: project.workspace.projectId,
|
||||
projectKey: projectDescriptor.projectKey,
|
||||
projectDisplayName: project.workspace.projectDisplayName,
|
||||
workspaceName: project.workspace.name,
|
||||
workspaceDirectory: project.workspace.workspaceDirectory,
|
||||
};
|
||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
||||
const importSessionId = "fixture-custom-title";
|
||||
await seedClaudeSessions({
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
});
|
||||
scenario = {
|
||||
project: openedProject,
|
||||
reuseTarget,
|
||||
projectName: project.workspace.projectDisplayName,
|
||||
projectRoot: repo.path,
|
||||
worktreeDirectory,
|
||||
unrelatedDirectory: unrelated.path,
|
||||
importSessionId,
|
||||
repoCleanup: repo.cleanup,
|
||||
unrelatedCleanup: unrelated.cleanup,
|
||||
};
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(scenario?.project.projectId).catch(() => undefined);
|
||||
await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await scenario?.repoCleanup().catch(() => undefined);
|
||||
await scenario?.unrelatedCleanup().catch(() => undefined);
|
||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
||||
|
||||
await test.step("the mobile sidebar exposes import in its footer", async () => {
|
||||
await flow.revealMobileEntryPoint();
|
||||
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
|
||||
});
|
||||
|
||||
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
|
||||
await flow.openGlobally();
|
||||
await flow.expectScope("Sessions on", false);
|
||||
await flow.expectRows({
|
||||
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
||||
folders: [
|
||||
[scenario.importSessionId, scenario.projectName],
|
||||
["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`],
|
||||
["fixture-unrelated", scenario.reuseTarget.projectDisplayName],
|
||||
],
|
||||
});
|
||||
await flow.expectProviderError("Broken ACP");
|
||||
await flow.expectProviderFilterFits(390);
|
||||
await flow.revealSession("fixture-unrelated");
|
||||
await capture(page, testInfo, "02-mobile-sheet-unscoped.png");
|
||||
});
|
||||
|
||||
await test.step("search narrows across the fixture corpus", async () => {
|
||||
await flow.search("invoice");
|
||||
await capture(page, testInfo, "03-mobile-search-narrowed.png");
|
||||
});
|
||||
|
||||
await test.step("load more grows the result set and then disappears", async () => {
|
||||
await flow.resetSearch();
|
||||
await capture(page, testInfo, "04-mobile-load-more-visible.png");
|
||||
await flow.loadMore();
|
||||
await capture(page, testInfo, "05-mobile-load-more-complete.png");
|
||||
});
|
||||
|
||||
await test.step("one provider fails inline and Retry settles", async () => {
|
||||
await flow.retryProvider(brokenProvider, "Broken ACP");
|
||||
await capture(page, testInfo, "06-mobile-provider-error-retry.png");
|
||||
});
|
||||
|
||||
await test.step("the selected row imports and opens the hydrated transcript", async () => {
|
||||
await flow.importSession(scenario.importSessionId);
|
||||
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
|
||||
await capture(page, testInfo, "08-mobile-agent-after-import.png");
|
||||
});
|
||||
|
||||
await test.step("workspace actions start scoped and can widen to the host", async () => {
|
||||
await flow.openFromWorkspaceHeader();
|
||||
await capture(page, testInfo, "09-mobile-workspace-scoped.png");
|
||||
await flow.showAll();
|
||||
await flow.expectRows({
|
||||
folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]],
|
||||
});
|
||||
await capture(page, testInfo, "10-mobile-workspace-show-all.png");
|
||||
|
||||
await flow.importSession("fixture-root-03");
|
||||
await flow.expectImportedIntoWorkspace(
|
||||
scenario.reuseTarget.workspaceId,
|
||||
"Review fixture item 3",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
await test.step("desktop shows the flat host-wide sheet", async () => {
|
||||
await flow.openGlobally();
|
||||
// The compact test may already have imported the newest fixture row, so this
|
||||
// asserts recency as an ordering between two rows nothing imports.
|
||||
await flow.expectRows({
|
||||
before: ["fixture-worktree", "fixture-unrelated"],
|
||||
folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]],
|
||||
});
|
||||
await flow.revealSession("fixture-unrelated");
|
||||
await capture(page, testInfo, "11-desktop-sheet-unscoped.png");
|
||||
await flow.close();
|
||||
});
|
||||
|
||||
await test.step("import matches the command but not Home", async () => {
|
||||
await flow.expectCommandCenterMatch();
|
||||
await capture(page, testInfo, "12-desktop-command-center-import.png");
|
||||
});
|
||||
});
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await page.screenshot({ path: outputPath, fullPage: true });
|
||||
await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name));
|
||||
}
|
||||
|
||||
async function seedClaudeSessions(input: {
|
||||
projectRoot: string;
|
||||
worktreeDirectory: string;
|
||||
unrelatedDirectory: string;
|
||||
importSessionId: string;
|
||||
}): Promise<void> {
|
||||
const sessions = [
|
||||
{
|
||||
cwd: input.projectRoot,
|
||||
id: input.importSessionId,
|
||||
title: "Invoice migration plan",
|
||||
prompt: "Review the invoice migration",
|
||||
answer: "The fixture transcript is ready.",
|
||||
},
|
||||
{
|
||||
cwd: input.worktreeDirectory,
|
||||
id: "fixture-worktree",
|
||||
title: "Review worktree fix",
|
||||
prompt: "Check the worktree import flow",
|
||||
},
|
||||
{
|
||||
cwd: input.unrelatedDirectory,
|
||||
id: "fixture-unrelated",
|
||||
title: "Unrelated directory notes",
|
||||
prompt: "Review the unrelated directory",
|
||||
},
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!,
|
||||
id: `fixture-root-${String(index + 1).padStart(2, "0")}`,
|
||||
title: `Root session ${String(index + 1).padStart(2, "0")}`,
|
||||
prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`,
|
||||
})),
|
||||
];
|
||||
const newest = Date.now() - 60_000;
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
const projectDirectory = path.join(
|
||||
claudeConfigDirectory,
|
||||
"projects",
|
||||
session.cwd.replace(/[^a-zA-Z0-9]/g, "-"),
|
||||
);
|
||||
await mkdir(projectDirectory, { recursive: true });
|
||||
const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`);
|
||||
const records = [
|
||||
{
|
||||
type: "user",
|
||||
uuid: `${session.id}-user`,
|
||||
message: { role: "user", content: session.prompt },
|
||||
cwd: session.cwd,
|
||||
sessionId: session.id,
|
||||
},
|
||||
...(session.answer
|
||||
? [
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: `${session.id}-assistant`,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: session.answer }],
|
||||
},
|
||||
cwd: session.cwd,
|
||||
sessionId: session.id,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "custom-title", customTitle: session.title, sessionId: session.id },
|
||||
];
|
||||
await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
expectFullscreenDiagramClosed,
|
||||
openDiagramFullscreen,
|
||||
requestDiagram,
|
||||
waitForDiagramTurnToComplete,
|
||||
zoomAndPanFullscreenDiagram,
|
||||
} from "../support/helpers/diagram";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const MERMAID_DIAGRAM = [
|
||||
"```mermaid",
|
||||
"flowchart LR",
|
||||
" Start --> Middle",
|
||||
" Middle --> Review",
|
||||
" Review --> Ship",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-fullscreen-",
|
||||
title: "Fullscreen Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await test.step("Open the conversation and wait for the rendered diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
|
||||
await zoomAndPanFullscreenDiagram(page);
|
||||
});
|
||||
|
||||
await test.step("The close action returns to the conversation", async () => {
|
||||
await closeDiagramFullscreen(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Escape closes the fullscreen diagram", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
|
||||
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
||||
await closeDiagramFullscreenFromOutside(page);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -35,6 +35,7 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
let diagramTurnCompleted!: Promise<void>;
|
||||
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-streaming-",
|
||||
@@ -48,15 +49,16 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
||||
await test.step("Open the conversation and request a diagram", async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
|
||||
});
|
||||
|
||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
|
||||
});
|
||||
|
||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await diagramTurnCompleted;
|
||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ test("chooses a metadata model and can return to automatic selection", async ({
|
||||
}, testInfo) => {
|
||||
await openMetadataGenerationSettings(page);
|
||||
|
||||
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
|
||||
"About Metadata generation",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
|
||||
@@ -119,7 +119,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
|
||||
@@ -121,7 +121,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
@@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
await openPluginSettings(page);
|
||||
@@ -159,13 +159,13 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||
@@ -173,7 +173,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await leavePluginSettings(page);
|
||||
await expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
|
||||
@@ -54,7 +54,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
@@ -12,10 +12,7 @@ import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
@@ -29,18 +26,11 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const recordComposerOpen = defineRpc({
|
||||
name: "composer.open",
|
||||
input: z.object({ workspaceId: z.string() }),
|
||||
output: z.object({ opened: z.boolean() }),
|
||||
});
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
@@ -114,23 +104,49 @@ function contributeClient(client) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
export default function contribute(client) {
|
||||
client.addSurface("collision", DirectCollisionSurface);
|
||||
client.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||
client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||
client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
|
||||
client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
|
||||
client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
|
||||
client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||
client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||
client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||
return contributeClient(client);
|
||||
}`;
|
||||
}
|
||||
|
||||
const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
|
||||
export const recordComposerOpen = defineRpc({
|
||||
name: "composer.open",
|
||||
input: z.object({ workspaceId: z.string() }),
|
||||
output: z.object({ opened: z.boolean() }),
|
||||
});`;
|
||||
|
||||
const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
export default function contribute(server) {
|
||||
server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||
return { opened: true };
|
||||
});
|
||||
plugin.addSurface("collision", DirectCollisionSurface);
|
||||
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
|
||||
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
|
||||
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
|
||||
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function writePluginSources(
|
||||
directory: string,
|
||||
input: { workspaceId: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await mkdir(path.join(directory, "shared"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)),
|
||||
writeFile(path.join(directory, "index.server.ts"), pluginServerSource),
|
||||
writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource),
|
||||
]);
|
||||
}
|
||||
|
||||
async function searchCommands(page: Page, query: string) {
|
||||
@@ -159,7 +175,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
|
||||
|
||||
async function openCompactSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
@@ -184,10 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
});
|
||||
|
||||
try {
|
||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -263,10 +279,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
modeId: "load-test",
|
||||
});
|
||||
const navigationAgentId = agent.id;
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: navigationAgentId,
|
||||
});
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
@@ -331,8 +347,9 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
// The sidebar's Search row dismisses the compact sidebar on its way to the
|
||||
// command center, so nothing has to close it after the command runs.
|
||||
await runCommand(page, "Open plugin agent");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||
await expect(
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
expectPrPaneChecks,
|
||||
expectPrPaneActivity,
|
||||
} from "../support/helpers/pr-pane";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
@@ -50,7 +50,6 @@ test.describe("PR pane", () => {
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -102,25 +101,21 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
await expectPrPaneChecks(page, {
|
||||
success: ["build-1", "build-2"],
|
||||
failure: ["deploy"],
|
||||
pending: ["security"],
|
||||
});
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
test("renders every seeded activity comment", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ const cases: ProviderSubagentCase[] = [
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
expectedName: "Verify OpenCode descriptor",
|
||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||
expectsUserMessage: false,
|
||||
providerConfig: { model: "openai/gpt-5.4" },
|
||||
prompt:
|
||||
'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.',
|
||||
|
||||
@@ -69,8 +69,8 @@ test.describe("Same-directory workspaces", () => {
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
|
||||
// Workspace B (same directory): the SAME directory-backed content is
|
||||
// visible even though the panel tabs belong to a different workspace.
|
||||
@@ -79,8 +79,8 @@ test.describe("Same-directory workspaces", () => {
|
||||
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||
await openChangesPanel(page);
|
||||
await expect(
|
||||
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectSidebarItemHidden,
|
||||
expectSidebarNavSettingsOrder,
|
||||
expectSidebarNavSettingsRow,
|
||||
expectSidebarOrder,
|
||||
expectStoredSidebarNav,
|
||||
leaveSettings,
|
||||
moveSidebarNavItemUp,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarNavPreferences,
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
test.describe("Sidebar items in Appearance settings", () => {
|
||||
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("the sidebar starts in the default order", async () => {
|
||||
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
|
||||
});
|
||||
|
||||
await test.step("the Sidebar section lists every item in the same order", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
||||
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
|
||||
"About Sidebar",
|
||||
);
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"history",
|
||||
"search",
|
||||
"schedules",
|
||||
]);
|
||||
await expectSidebarNavSettingsRow(page, {
|
||||
key: "history",
|
||||
label: "History",
|
||||
visible: true,
|
||||
});
|
||||
// Items with a keyboard shortcut badge it next to their name. Chords render
|
||||
// with Ctrl off macOS, which is what the browser project runs on.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-item-new-workspace").getByText("Ctrl+N", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("moving Schedules up twice lifts it above History", async () => {
|
||||
await moveSidebarNavItemUp(page, "schedules");
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"history",
|
||||
"schedules",
|
||||
"search",
|
||||
]);
|
||||
await moveSidebarNavItemUp(page, "schedules");
|
||||
await expectSidebarNavSettingsOrder(page, [
|
||||
"new-workspace",
|
||||
"schedules",
|
||||
"history",
|
||||
"search",
|
||||
]);
|
||||
|
||||
await leaveSettings(page);
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "history", "search"]);
|
||||
});
|
||||
|
||||
await test.step("turning History off removes it from the sidebar", async () => {
|
||||
await openSidebarNavSettings(page);
|
||||
await setSidebarNavItemVisible(page, "history", false);
|
||||
await expectStoredSidebarNav(page, [
|
||||
{ key: "new-workspace", visible: true },
|
||||
{ key: "schedules", visible: true },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: true },
|
||||
]);
|
||||
|
||||
await leaveSettings(page);
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
|
||||
});
|
||||
|
||||
await test.step("the sidebar keeps that shape across a reload", async () => {
|
||||
await page.reload();
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders no top-level items when every one is turned off", async ({ page }) => {
|
||||
await seedSidebarNavPreferences(page, [
|
||||
{ key: "new-workspace", visible: false },
|
||||
{ key: "history", visible: false },
|
||||
{ key: "search", visible: false },
|
||||
{ key: "schedules", visible: false },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
// The sidebar itself still renders; only its top-level nav items are gone.
|
||||
await expect(page.locator('[data-testid="sidebar-settings"]:visible')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarItemHidden(page, "new-workspace");
|
||||
await expectSidebarItemHidden(page, "history");
|
||||
await expectSidebarItemHidden(page, "search");
|
||||
await expectSidebarItemHidden(page, "schedules");
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
expectTurnCopyButton,
|
||||
} from "../support/helpers/agent-stream";
|
||||
import {
|
||||
expectReconnectingToastDebounced,
|
||||
expectReconnectingToastGone,
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
@@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => {
|
||||
"true",
|
||||
);
|
||||
await gate.drop();
|
||||
await expectReconnectingToastDebounced(page);
|
||||
await expectReconnectingToastVisible(page);
|
||||
await gate.waitForBlockedConnection();
|
||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||
await expect(
|
||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
// Hold the first authoritative catch-up response so the assertion observes
|
||||
// the reconnect boundary instead of racing a socket that has not reopened yet.
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expectReconnectingToastGone(page);
|
||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||
exact: true,
|
||||
@@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
|
||||
const TITLE_MAX_LENGTH = 80;
|
||||
|
||||
function normalizedTitle(title: string): string {
|
||||
return title.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function expectedTooltipTitle(title: string): string {
|
||||
const singleLineTitle = normalizedTitle(title);
|
||||
return singleLineTitle.length <= TITLE_MAX_LENGTH
|
||||
? singleLineTitle
|
||||
: `${singleLineTitle.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, agent: { id: string; workspaceId: string }): Promise<void> {
|
||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
async function openAgentTabTooltip(page: Page, agentId: string, title: string): Promise<Locator> {
|
||||
await page.getByRole("button", { name: normalizedTitle(title), exact: true }).hover();
|
||||
const tooltip = page.getByTestId(`workspace-tab-tooltip-agent_${agentId}`);
|
||||
await expect(tooltip).toBeVisible({ timeout: 10_000 });
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
async function expectTwoRowAgentSummary(
|
||||
tooltip: Locator,
|
||||
input: { title: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const title = tooltip.getByText(expectedTooltipTitle(input.title), { exact: true });
|
||||
const id = tooltip.getByText(input.agentId.slice(0, 7), { exact: true });
|
||||
const activity = tooltip.getByText(/^(just now|\d+[mhd] ago|[A-Z][a-z]{2} \d{1,2})$/);
|
||||
|
||||
await expect(title).toBeVisible();
|
||||
await expect(id).toBeVisible();
|
||||
await expect(tooltip.getByText("·", { exact: true })).toBeVisible();
|
||||
await expect(activity).toBeVisible();
|
||||
expect((await title.boundingBox())?.y).toBeLessThan((await id.boundingBox())?.y ?? 0);
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab tooltip", () => {
|
||||
test("summarizes a multiline agent title above its ID and last activity", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-tooltip-" });
|
||||
|
||||
try {
|
||||
const title = [
|
||||
"Short first line",
|
||||
"followed by a much longer continuation that should be joined before the complete title is truncated to a useful tooltip length",
|
||||
].join("\n");
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await openAgent(page, agent);
|
||||
const tooltip = await openAgentTabTooltip(page, agent.id, title);
|
||||
|
||||
await expectTwoRowAgentSummary(tooltip, { title, agentId: agent.id });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import {
|
||||
createIdleAgent,
|
||||
createMockIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
const agent = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
@@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await daemonGate.waitForBlockedConnection();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
@@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
createIdleAgent(primaryWorkspace.client, {
|
||||
createMockIdleAgent(primaryWorkspace.client, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title: "Active host agent",
|
||||
}),
|
||||
createIdleAgent(secondaryWorkspace.client, {
|
||||
createMockIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: "Inactive host agent",
|
||||
@@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
returnHomeFromWorkspace,
|
||||
leaveWorkspaceViaHistory,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
@@ -195,7 +195,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await returnHomeFromWorkspace(page);
|
||||
await leaveWorkspaceViaHistory(page);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
|
||||
@@ -35,6 +35,8 @@ const metroTest = base.extend({
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{
|
||||
e2eDaemonConfig: Record<string, unknown> | undefined;
|
||||
e2eDaemonEnvironment: Record<string, string>;
|
||||
e2eForkProviders: string[];
|
||||
e2eInjectPaseoTools: boolean;
|
||||
e2eWorker: void;
|
||||
@@ -43,9 +45,17 @@ const daemonTest = metroTest.extend<
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||
e2eDaemonConfig: [undefined, { scope: "worker", option: true }],
|
||||
e2eDaemonEnvironment: [{}, { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||
async (
|
||||
{ e2eDaemonConfig, e2eDaemonEnvironment, e2eForkProviders, e2eInjectPaseoTools },
|
||||
provide,
|
||||
workerInfo,
|
||||
) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
daemonConfig: e2eDaemonConfig,
|
||||
environment: e2eDaemonEnvironment,
|
||||
forkProviders: e2eForkProviders,
|
||||
injectPaseoTools: e2eInjectPaseoTools,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||
@@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
interface PositionedToolCall {
|
||||
locator: Locator;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
async function readRenderedToolCalls(toolCalls: Locator): Promise<PositionedToolCall[]> {
|
||||
const entries = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
locator: toolCalls.nth(index),
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
return entries.filter(
|
||||
(entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) {
|
||||
return toolCalls.toSorted(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - targetY),
|
||||
)[0];
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
@@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
||||
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
||||
expect(
|
||||
candidate,
|
||||
initialCandidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds,
|
||||
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
if (!currentButtonBounds) return false;
|
||||
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
||||
const closest = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
);
|
||||
if (!closest) return false;
|
||||
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
||||
if (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
);
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY));
|
||||
await getVisibleChatScroll(page).evaluate((scroll, delta) => {
|
||||
(scroll as HTMLElement).scrollTop += delta;
|
||||
}, deltaY);
|
||||
return false;
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2;
|
||||
const alignedToolCall = closestToolCallToY(
|
||||
await readRenderedToolCalls(toolCalls),
|
||||
currentButtonCenterY,
|
||||
)?.locator;
|
||||
expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined();
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
alignedToolCall!.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
@@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
|
||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
async function seedIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
provider: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
...provider,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
title: input.title,
|
||||
@@ -84,6 +84,32 @@ export async function createIdleAgent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
// OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with
|
||||
// auto_accept for unattended full access — mode validation now rejects modes
|
||||
// the provider doesn't define.
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMockIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
return seedIdleAgent(client, input, {
|
||||
provider: "mock",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
|
||||
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.press("Meta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
@@ -83,6 +83,7 @@ export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<v
|
||||
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
|
||||
const expectedActions = [
|
||||
{ title: "New workspace", shortcut: "⌘N" },
|
||||
{ title: "Import session" },
|
||||
{ title: "History" },
|
||||
{ title: "Schedules" },
|
||||
{ title: "Settings", shortcut: "⌘," },
|
||||
@@ -105,6 +106,9 @@ export async function expectPrimaryCommandCenterActions(panel: Locator): Promise
|
||||
await input.fill("home");
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
|
||||
await input.fill("import");
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Import session" })).toBeVisible();
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
|
||||
await input.fill("");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-command-center-search").click();
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
|
||||
@@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
let blockedConnectionCount = 0;
|
||||
let blockedConnectionCountAtDrop = 0;
|
||||
const blockedConnectionWaiters = new Set<() => void>();
|
||||
let latestServer: WebSocketRoute | null = null;
|
||||
const directoryStarts: DirectoryRequestStartCounts = {
|
||||
subscribed: { agents: 0, workspaces: 0 },
|
||||
@@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
blockedConnectionWaiters.clear();
|
||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||
return;
|
||||
}
|
||||
@@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
forward?.();
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
blockedConnectionCountAtDrop = blockedConnectionCount;
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
@@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
),
|
||||
);
|
||||
},
|
||||
async waitForBlockedConnection(): Promise<void> {
|
||||
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
||||
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
||||
},
|
||||
restore(): void {
|
||||
acceptingConnections = true;
|
||||
},
|
||||
|
||||
@@ -25,13 +25,28 @@ export async function expectDiagramWithLabels(
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||
const { diagram, svg } = renderedDiagram(page);
|
||||
const samples = 80;
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||
await expect(svg).toBeVisible({ timeout: 500 });
|
||||
await page.waitForTimeout(25);
|
||||
export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
page: Page,
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
let turnCompleted = false;
|
||||
const completed = completion.then(() => {
|
||||
turnCompleted = true;
|
||||
return true;
|
||||
});
|
||||
for (;;) {
|
||||
expect(await diagram.isVisible()).toBe(true);
|
||||
if (turnCompleted) return completion;
|
||||
const box = await diagram.boundingBox();
|
||||
if (turnCompleted) return completion;
|
||||
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
|
||||
expect(
|
||||
box?.height,
|
||||
"the inline diagram should remain tall enough to read",
|
||||
).toBeGreaterThanOrEqual(56);
|
||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
||||
if (didComplete) return completion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,3 +61,90 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
|
||||
|
||||
function diagramSvg(page: Page, viewportTestId: string) {
|
||||
return page
|
||||
.getByTestId(`${viewportTestId}-canvas`)
|
||||
.last()
|
||||
.locator("iframe")
|
||||
.contentFrame()
|
||||
.locator("#diagram svg");
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
|
||||
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
|
||||
* step left the cursor inside the viewport.
|
||||
*/
|
||||
export async function openDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByTestId("mermaid-viewport-canvas").last().hover();
|
||||
await page.getByTestId("mermaid-fullscreen").last().click();
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreen(page: Page): Promise<void> {
|
||||
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
|
||||
await page.getByTestId("mermaid-fullscreen-close").click();
|
||||
}
|
||||
|
||||
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
const initial = await svg.boundingBox();
|
||||
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
|
||||
await page.mouse.wheel(0, -100);
|
||||
|
||||
const zoomed = await svg.boundingBox();
|
||||
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
|
||||
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
|
||||
|
||||
const viewport = await canvas.boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
viewport!.x + viewport!.width / 2 - 120,
|
||||
viewport!.y + viewport!.height / 2,
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
const panned = await svg.boundingBox();
|
||||
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
|
||||
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
|
||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
||||
const viewport = await canvas.boundingBox();
|
||||
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
|
||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
||||
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
||||
|
||||
const x = viewport!.x + viewport!.width / 2;
|
||||
const y =
|
||||
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
|
||||
? viewport!.y + viewport!.height - 4
|
||||
: viewport!.y + 4;
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagram(
|
||||
page: Page,
|
||||
labels: readonly string[],
|
||||
): Promise<void> {
|
||||
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
|
||||
await expect(viewport).toBeVisible({ timeout: 30_000 });
|
||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
||||
for (const label of labels) {
|
||||
await expect(svg).toContainText(label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
|
||||
}
|
||||
|
||||
async function openCommandCenter(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open command center" }).click();
|
||||
await page.getByTestId("sidebar-search").click();
|
||||
}
|
||||
|
||||
export class DirectoryBootstrapScenario {
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface E2EWorker {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface E2EWorkerOptions {
|
||||
forkProviders?: string[];
|
||||
injectPaseoTools?: boolean;
|
||||
daemonConfig?: Record<string, unknown>;
|
||||
environment?: Record<string, string>;
|
||||
}
|
||||
|
||||
function resolveOptionalHome(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -77,31 +84,33 @@ if (origin === fixtureRemote) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const isFork = args.includes("2");
|
||||
const pr = {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
const isFork = args.some((arg) => arg === "number=2");
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
isCrossRepository: isFork,
|
||||
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
@@ -156,7 +165,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||
options: E2EWorkerOptions = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
@@ -169,6 +178,14 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
// Worker-scoped fixture config lets a spec exercise provider discovery without
|
||||
// reading the developer's provider state or sharing configuration with other specs.
|
||||
if (options.daemonConfig) {
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify(options.daemonConfig, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
if (options.injectPaseoTools) {
|
||||
await enablePaseoTools(paseoHome);
|
||||
}
|
||||
@@ -179,6 +196,7 @@ export async function startE2EWorker(
|
||||
NODE_ENV: "development",
|
||||
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
||||
...options.environment,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
|
||||
|
||||
const rowTestId = (id: string) => `import-session-session-claude-${id}`;
|
||||
const rowFolderTestId = (id: string) => `import-session-row-folder-claude-${id}`;
|
||||
|
||||
export class ImportSessionFlow {
|
||||
constructor(private readonly page: Page) {}
|
||||
async openWorkspace(workspaceId: string, viewport: { width: number; height: number }) {
|
||||
await this.page.setViewportSize(viewport);
|
||||
await gotoAppShell(this.page);
|
||||
await this.page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await expect(this.page.getByRole("button", { name: "Workspace actions" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
async revealMobileEntryPoint() {
|
||||
await openMobileAgentSidebar(this.page);
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
const button = this.page.getByTestId("sidebar-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await button.hover();
|
||||
await expect(this.page.getByText("Import session", { exact: true })).toBeVisible();
|
||||
}
|
||||
async openGlobally() {
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await this.page.getByTestId("sidebar-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
await this.page.getByRole("button", { name: "Workspace actions" }).click();
|
||||
await this.page.getByTestId("workspace-header-import-agent").click();
|
||||
await this.expectScope("This workspace");
|
||||
await expect(this.page.getByTestId("import-session-show-all")).toBeVisible();
|
||||
await expect(this.page.getByText("Workspace actions", { exact: true })).not.toBeVisible();
|
||||
}
|
||||
async expectScope(text: string, exact = true) {
|
||||
const scope = this.page.getByTestId("import-session-scope");
|
||||
if (exact) await expect(scope).toHaveText(text);
|
||||
else await expect(scope).toContainText(text);
|
||||
await expect(scope).toBeVisible();
|
||||
}
|
||||
async expectRows(input: {
|
||||
first?: string[];
|
||||
before?: [string, string];
|
||||
folders?: Array<[string, string]>;
|
||||
}) {
|
||||
const ids = await this.rowIds();
|
||||
if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId));
|
||||
if (input.before) {
|
||||
expect(ids.indexOf(rowTestId(input.before[0]))).toBeGreaterThanOrEqual(0);
|
||||
expect(ids.indexOf(rowTestId(input.before[0]))).toBeLessThan(
|
||||
ids.indexOf(rowTestId(input.before[1])),
|
||||
);
|
||||
}
|
||||
for (const [id, folder] of input.folders ?? []) {
|
||||
await expect(this.page.getByTestId(rowFolderTestId(id))).toHaveText(folder);
|
||||
}
|
||||
}
|
||||
async expectProviderError(label: string) {
|
||||
await expect(this.page.getByTestId("import-session-provider-errors")).toContainText(
|
||||
`Could not load ${label} sessions`,
|
||||
);
|
||||
}
|
||||
async expectProviderFilterFits(width: number) {
|
||||
await expect(this.page.getByTestId("import-session-scope")).toBeInViewport();
|
||||
const filter = this.page.getByRole("button", { name: "Filter: All" });
|
||||
await expect(filter).toBeInViewport();
|
||||
const bounds = await filter.boundingBox();
|
||||
expect(bounds?.x ?? width).toBeGreaterThanOrEqual(0);
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
async revealSession(id: string) {
|
||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
||||
}
|
||||
async search(query: string) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
await expect(this.page.getByText("Invoice migration plan", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByText("Root session 01", { exact: true })).toHaveCount(0);
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async resetSearch() {
|
||||
await this.page.getByTestId("import-session-search").fill("");
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toBeVisible();
|
||||
}
|
||||
async loadMore() {
|
||||
await this.page.getByTestId("import-session-load-more").click();
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
await this.expectProviderError(label);
|
||||
await this.page.getByTestId(`import-session-retry-${provider}`).click();
|
||||
await expect(this.page.getByTestId(`import-session-retry-${provider}`)).toBeEnabled();
|
||||
await this.expectProviderError(label);
|
||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||
}
|
||||
async importSession(id: string) {
|
||||
await this.page.getByTestId(rowTestId(id)).click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
async expectTranscript(userText: string, assistantText: string) {
|
||||
await expect(this.page.getByTestId("user-message")).toContainText(userText);
|
||||
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
|
||||
}
|
||||
async showAll() {
|
||||
await this.page.getByTestId("import-session-show-all").click();
|
||||
await expect(this.page.getByTestId("import-session-scope")).toContainText("Sessions on");
|
||||
}
|
||||
async expectImportedIntoWorkspace(workspaceId: string, userText: string) {
|
||||
await expect(this.page).toHaveURL(buildHostWorkspaceRoute(getServerId(), workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace.getByTestId("user-message")).toContainText(userText);
|
||||
}
|
||||
async close() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
const panel = await openCommandCenter(this.page);
|
||||
await panel.getByTestId("command-center-input").fill("import");
|
||||
await expect(panel.getByText("Import session", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("Home", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
private async expectSheetReady() {
|
||||
const sheet = this.page.getByTestId("import-session-sheet");
|
||||
await expect(sheet).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sheet.getByText("Loading sessions...", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
private async rowIds() {
|
||||
return await this.page
|
||||
.locator('[data-testid^="import-session-session-claude-"]')
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
@@ -20,23 +19,31 @@ export async function expectPrPaneState(
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
export async function expectPrPaneChecks(
|
||||
page: Page,
|
||||
checks: { success: string[]; failure: string[]; pending: string[] },
|
||||
): Promise<void> {
|
||||
const section = page.getByTestId("pr-pane-checks");
|
||||
const groups = [
|
||||
{ status: "failure", label: /failing checks?$/, names: checks.failure },
|
||||
{ status: "pending", label: /in progress checks?$/, names: checks.pending },
|
||||
{ status: "success", label: /successful checks?$/, names: checks.success },
|
||||
];
|
||||
|
||||
for (const group of groups) {
|
||||
const groupSection = section.getByTestId(`pr-pane-check-group-${group.status}`);
|
||||
await expect(groupSection.getByRole("button", { name: group.label })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
for (const name of group.names) {
|
||||
await expect(groupSection.getByText(name, { exact: true })).toHaveCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
||||
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
export async function expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
|
||||
const activity = page.getByTestId("pr-pane-activity-row");
|
||||
for (const body of bodies) {
|
||||
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
/** Persisted nav key -> the testID the app shell renders that item with. */
|
||||
const SHELL_ROW_TEST_IDS = {
|
||||
"new-workspace": "sidebar-global-new-workspace",
|
||||
history: "sidebar-sessions",
|
||||
search: "sidebar-search",
|
||||
schedules: "sidebar-schedules",
|
||||
} as const;
|
||||
|
||||
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
|
||||
|
||||
export interface SidebarNavPreference {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function shellRow(page: Page, key: SidebarNavKey): Locator {
|
||||
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
|
||||
// sidebar mounted, so the pinned row is the first visible match.
|
||||
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
|
||||
}
|
||||
|
||||
function settingsRow(page: Page, key: SidebarNavKey): Locator {
|
||||
return page.getByTestId(`sidebar-nav-item-${key}`);
|
||||
}
|
||||
|
||||
function itemLabel(key: SidebarNavKey): string {
|
||||
return {
|
||||
"new-workspace": "New workspace",
|
||||
history: "History",
|
||||
search: "Search",
|
||||
schedules: "Schedules",
|
||||
}[key];
|
||||
}
|
||||
|
||||
async function rowTop(locator: Locator): Promise<number | null> {
|
||||
const box = await locator.boundingBox();
|
||||
return box?.y ?? null;
|
||||
}
|
||||
|
||||
export async function seedSidebarNavPreferences(
|
||||
page: Page,
|
||||
preferences: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarNavItems }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function leaveSettings(page: Page): Promise<void> {
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
}
|
||||
|
||||
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
|
||||
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function setSidebarNavItemVisible(
|
||||
page: Page,
|
||||
key: SidebarNavKey,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = settingsRow(page, key).getByRole("switch", {
|
||||
name: itemLabel(key),
|
||||
exact: true,
|
||||
});
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectSidebarNavSettingsRow(
|
||||
page: Page,
|
||||
expected: { key: SidebarNavKey; label: string; visible: boolean },
|
||||
): Promise<void> {
|
||||
const row = settingsRow(page, expected.key);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
|
||||
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
|
||||
await expect(toggle).toHaveAccessibleName(expected.label);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
|
||||
}
|
||||
|
||||
export async function expectSidebarNavSettingsOrder(
|
||||
page: Page,
|
||||
keys: SidebarNavKey[],
|
||||
): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
|
||||
}
|
||||
|
||||
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
|
||||
}
|
||||
|
||||
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
|
||||
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectStoredSidebarNav(
|
||||
page: Page,
|
||||
expected: SidebarNavPreference[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
|
||||
}, APP_SETTINGS_KEY),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
async function expectVerticalOrder(
|
||||
keys: SidebarNavKey[],
|
||||
locate: (key: SidebarNavKey) => Locator,
|
||||
subject: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
||||
)
|
||||
.toEqual(keys);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ class StartupAssertions {
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -276,9 +276,9 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
export async function leaveWorkspaceViaHistory(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/sessions$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
|
||||
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
const changedFile = page
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await expect(changedFile).toBeVisible({ timeout });
|
||||
await changedFile.click();
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,14 +112,6 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastDebounced(
|
||||
page: Page,
|
||||
options?: { durationMs?: number },
|
||||
): Promise<void> {
|
||||
await page.waitForTimeout(options?.durationMs ?? 750);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 });
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "npm run start:expo",
|
||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
@@ -35,10 +35,7 @@
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:embedded-webviews": "npm run build:terminal-webview && npm run build:source-editor-webview",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"build:source-editor-webview": "node ./scripts/build-source-editor-webview-html.mjs",
|
||||
"build:source-editor-webview:check": "node ./scripts/build-source-editor-webview-html.mjs --check",
|
||||
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -165,5 +162,10 @@
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.105.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"reanimated": {
|
||||
"staticFeatureFlags": {
|
||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import esbuild from "esbuild";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(scriptDirectory, "..");
|
||||
const repoRoot = path.resolve(appRoot, "../..");
|
||||
const entry = path.join(appRoot, "src/source-editor/codemirror/webview-entry.ts");
|
||||
const output = path.join(appRoot, "src/source-editor/codemirror/webview-html.ts");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
async function resolveTsPath(basePath) {
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
`${basePath}.js`,
|
||||
`${basePath}.jsx`,
|
||||
path.join(basePath, "index.ts"),
|
||||
path.join(basePath, "index.tsx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if ((await fs.stat(candidate)).isFile()) return candidate;
|
||||
} catch {
|
||||
// Try the next TypeScript resolution candidate.
|
||||
}
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
const aliasPlugin = {
|
||||
name: "paseo-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@\// }, async (args) => ({
|
||||
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["ios15", "chrome100"],
|
||||
plugins: [aliasPlugin],
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
const javascript = result.outputFiles[0]?.text;
|
||||
if (!javascript) throw new Error("source editor WebView bundle produced no JavaScript");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<style>
|
||||
html, body, #source-editor-root, #source-editor-host {
|
||||
width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
|
||||
}
|
||||
html, body { overscroll-behavior: none; }
|
||||
#source-editor-root, #source-editor-host { display: flex; min-width: 0; min-height: 0; }
|
||||
#source-editor-host { flex: 1; }
|
||||
* { -webkit-tap-highlight-color: transparent; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>${javascript}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const contents = `// Generated by packages/app/scripts/build-source-editor-webview-html.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export const sourceEditorWebViewHtml =
|
||||
${JSON.stringify(html)};
|
||||
`;
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await fs.readFile(output, "utf8").catch(() => null);
|
||||
if (existing !== contents) {
|
||||
throw new Error(
|
||||
`${path.relative(repoRoot, output)} is stale. Run npm --prefix packages/app run build:source-editor-webview.`,
|
||||
);
|
||||
}
|
||||
console.log(`${path.relative(repoRoot, output)} is current (${html.length} bytes)`);
|
||||
} else {
|
||||
await fs.writeFile(output, contents);
|
||||
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
|
||||
}
|
||||
@@ -65,7 +65,6 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
|
||||
[
|
||||
assistantId,
|
||||
{
|
||||
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||
durationMs: 2_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamHistoryRowRevision } from "./strategy";
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
}
|
||||
|
||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||
let variants = historyRowDisplayVariants.get(item);
|
||||
if (!variants) {
|
||||
variants = {};
|
||||
historyRowDisplayVariants.set(item, variants);
|
||||
}
|
||||
const key = compact ? "compact" : "regular";
|
||||
variants[key] ??= { ...item };
|
||||
return variants[key];
|
||||
}
|
||||
|
||||
// Item identity is the render signal for history rows: the row boundary in view.tsx bails out
|
||||
// until its item changes. Every viewport runs its history through this hook so a history host
|
||||
// whose tool-call group keeps updating from the live head, a group whose expanded state changed,
|
||||
// or a breakpoint change reaches the row as a fresh identity. Unchanged rows keep their identity,
|
||||
// which is what limits a live update to the rows it touched.
|
||||
export function useRevisedHistoryRows(
|
||||
items: StreamItem[],
|
||||
revision: StreamHistoryRowRevision | undefined,
|
||||
): StreamItem[] {
|
||||
const globalDisplayState = revision?.globalDisplayState ?? false;
|
||||
const displayStateById = revision?.displayStateById;
|
||||
const contentById = revision?.contentById;
|
||||
const globallyRevisedRows = useMemo(
|
||||
() => items.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState)),
|
||||
[items, globalDisplayState],
|
||||
);
|
||||
const displayStateRevisedRows = useMemo(
|
||||
() => globallyRevisedRows.map((item) => (displayStateById?.has(item.id) ? { ...item } : item)),
|
||||
[globallyRevisedRows, displayStateById],
|
||||
);
|
||||
return useMemo(
|
||||
() => displayStateRevisedRows.map((item) => (contentById?.has(item.id) ? { ...item } : item)),
|
||||
[displayStateRevisedRows, contentById],
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,6 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
|
||||
|
||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
durationMs: 8000,
|
||||
};
|
||||
@@ -366,6 +365,23 @@ describe("layoutStream", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps layout item identity for rows whose layout did not change", () => {
|
||||
const user = userMessage("u1", 1);
|
||||
const first = toolCall("tool-1", 2);
|
||||
const second = toolCall("tool-2", 3);
|
||||
const before = layoutFor({ platform: "android", tail: [user, first, second] });
|
||||
|
||||
const third = toolCall("tool-3", 4);
|
||||
const after = layoutFor({ platform: "android", tail: [user, first, second, third] });
|
||||
|
||||
// A newest-first list shifts every index, but only the rows next to the insertion change.
|
||||
expect(findLayoutItem(after, user.id)).toBe(findLayoutItem(before, user.id));
|
||||
expect(findLayoutItem(after, first.id)).toBe(findLayoutItem(before, first.id));
|
||||
expect(findLayoutItem(after, second.id)).not.toBe(findLayoutItem(before, second.id));
|
||||
expect(findLayoutItem(after, second.id).isLastInToolSequence).toBe(false);
|
||||
expect(findLayoutItem(after, third.id).isLastInToolSequence).toBe(true);
|
||||
});
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface TurnFooterHost {
|
||||
|
||||
export interface StreamLayoutItem {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
aboveItem: StreamItem | null;
|
||||
belowItem: StreamItem | null;
|
||||
gapBelow: number;
|
||||
@@ -232,60 +230,109 @@ function getSegmentNeighbor(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
// Last layout emitted for each stream item. A row only rerenders when its layout item identity
|
||||
// changes, so an item whose render-relevant layout is unchanged must keep its previous object even
|
||||
// when the surrounding array was rebuilt (a new row shifts every index in a newest-first list).
|
||||
const previousLayoutItemByStreamItem = new WeakMap<StreamItem, StreamLayoutItem>();
|
||||
|
||||
return {
|
||||
item,
|
||||
index,
|
||||
items: input.items,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
};
|
||||
function areTurnFooterHostsEqual(
|
||||
left: TurnFooterHost | null,
|
||||
right: TurnFooterHost | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return (
|
||||
left.itemId === right.itemId &&
|
||||
left.timing === right.timing &&
|
||||
left.startIndex === right.startIndex &&
|
||||
left.items === right.items
|
||||
);
|
||||
}
|
||||
|
||||
function areLayoutItemsEquivalent(previous: StreamLayoutItem, next: StreamLayoutItem): boolean {
|
||||
return (
|
||||
previous.item === next.item &&
|
||||
previous.aboveItem === next.aboveItem &&
|
||||
previous.belowItem === next.belowItem &&
|
||||
previous.gapBelow === next.gapBelow &&
|
||||
previous.assistantSpacing === next.assistantSpacing &&
|
||||
areTurnFooterHostsEqual(previous.completedFooter, next.completedFooter) &&
|
||||
previous.toolSequence === next.toolSequence &&
|
||||
previous.isFirstInUserGroup === next.isFirstInUserGroup &&
|
||||
previous.isLastInUserGroup === next.isLastInUserGroup &&
|
||||
previous.isLastInToolSequence === next.isLastInToolSequence &&
|
||||
previous.frameOrder === next.frameOrder &&
|
||||
previous.phase === next.phase
|
||||
);
|
||||
}
|
||||
|
||||
function shareLayoutItem(next: StreamLayoutItem): StreamLayoutItem {
|
||||
const previous = previousLayoutItemByStreamItem.get(next.item);
|
||||
if (previous && areLayoutItemsEquivalent(previous, next)) {
|
||||
return previous;
|
||||
}
|
||||
previousLayoutItemByStreamItem.set(next.item, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
|
||||
}
|
||||
|
||||
function layoutSegmentItem(
|
||||
input: LayoutSegmentInput,
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
): StreamLayoutItem {
|
||||
const aboveItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "above",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryAboveItem,
|
||||
});
|
||||
const belowItem = getSegmentNeighbor({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
relation: "below",
|
||||
boundaryIndex: input.boundaryIndex,
|
||||
boundaryItem: input.boundaryBelowItem,
|
||||
});
|
||||
const completedFooter = resolveCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
const assistantSpacing = getAssistantBlockSpacing({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null),
|
||||
});
|
||||
|
||||
return shareLayoutItem({
|
||||
item,
|
||||
aboveItem,
|
||||
belowItem,
|
||||
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||
assistantSpacing,
|
||||
completedFooter,
|
||||
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||
isLastInToolSequence:
|
||||
isToolSequenceItem(item) &&
|
||||
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||
frameOrder: input.frameOrder,
|
||||
phase: input.phase,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import { findMountedWindowStart } from "./history-window";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
@@ -69,12 +71,36 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
startedAt: tail[2]?.timestamp,
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mounted boundary stable when a transformer filters an earlier item", () => {
|
||||
const tail = [
|
||||
userMessage("filtered", 1),
|
||||
assistantMessage("hidden", 2),
|
||||
userMessage("visible-u", 3),
|
||||
assistantMessage("visible-a", 4),
|
||||
];
|
||||
|
||||
const projectedTail = projectPluginTimelineItems(tail, ({ sourceId }) =>
|
||||
sourceId === "filtered" ? [] : undefined,
|
||||
);
|
||||
const historyStart = findMountedWindowStart({ items: projectedTail, minMountedCount: 2 });
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail: projectedTail,
|
||||
head: [],
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
historyStart,
|
||||
});
|
||||
|
||||
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
|
||||
});
|
||||
|
||||
it("keeps head separate from committed history on desktop web", () => {
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
@@ -176,7 +202,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -196,7 +221,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
@@ -63,24 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
}
|
||||
|
||||
const historyRowDisplayVariants = new WeakMap<StreamItem, HistoryRowDisplayVariants>();
|
||||
|
||||
function getHistoryRowDisplayVariant(item: StreamItem, compact: boolean): StreamItem {
|
||||
let variants = historyRowDisplayVariants.get(item);
|
||||
if (!variants) {
|
||||
variants = {};
|
||||
historyRowDisplayVariants.set(item, variants);
|
||||
}
|
||||
const key = compact ? "compact" : "regular";
|
||||
variants[key] ??= { ...item };
|
||||
return variants[key];
|
||||
}
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
}
|
||||
@@ -138,27 +121,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||
// Keep unchanged item identities intact so live updates only rerender rows
|
||||
// whose projected content or local display state actually changed. A rare
|
||||
// breakpoint change intentionally refreshes the whole history window.
|
||||
const globallyRevisedHistoryRows = useMemo(() => {
|
||||
const globalDisplayState = historyRowRevision?.globalDisplayState ?? false;
|
||||
return historyItems.map((item) => getHistoryRowDisplayVariant(item, globalDisplayState));
|
||||
}, [historyItems, historyRowRevision?.globalDisplayState]);
|
||||
const displayStateHistoryRows = useMemo(
|
||||
() =>
|
||||
globallyRevisedHistoryRows.map((item) =>
|
||||
historyRowRevision?.displayStateById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[globallyRevisedHistoryRows, historyRowRevision?.displayStateById],
|
||||
);
|
||||
const historyRows = useMemo(
|
||||
() =>
|
||||
displayStateHistoryRows.map((item) =>
|
||||
historyRowRevision?.contentById.has(item.id) ? { ...item } : item,
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
|
||||
@@ -252,6 +252,74 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rerenders only the history row whose content revision changed", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const host = userMessage(1);
|
||||
const other = userMessage(2);
|
||||
const renderCountById = new Map<string, number>();
|
||||
const HistoryRow = React.memo(function HistoryRow({ item }: { item: StreamItem }) {
|
||||
renderCountById.set(item.id, (renderCountById.get(item.id) ?? 0) + 1);
|
||||
return <div>{item.id}</div>;
|
||||
});
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [host, other],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: {
|
||||
...createRenderers(vi.fn()),
|
||||
renderHistoryMountedRow: (item) => <HistoryRow item={item} />,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
const unrevised = {
|
||||
contentById: new Set<string>(),
|
||||
displayStateById: new Set<string>(),
|
||||
globalDisplayState: false,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(strategy.render({ ...renderInput, historyRowRevision: unrevised }));
|
||||
});
|
||||
expect(renderCountById.get(host.id)).toBe(1);
|
||||
expect(renderCountById.get(other.id)).toBe(1);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
historyRowRevision: { ...unrevised, contentById: new Set([host.id]) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(renderCountById.get(host.id)).toBe(2);
|
||||
expect(renderCountById.get(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
shouldAdjustScrollForVirtualRowResize,
|
||||
} from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
@@ -279,7 +280,8 @@ function isScrollContainerOverscrolledPastBottom(
|
||||
|
||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||
const {
|
||||
segments,
|
||||
segments: inputSegments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision,
|
||||
boundary,
|
||||
renderers,
|
||||
@@ -296,6 +298,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const historyVirtualized = useRevisedHistoryRows(
|
||||
inputSegments.historyVirtualized,
|
||||
historyRowRevision,
|
||||
);
|
||||
const historyMounted = useRevisedHistoryRows(inputSegments.historyMounted, historyRowRevision);
|
||||
const segments = useMemo(
|
||||
() => ({ ...inputSegments, historyVirtualized, historyMounted }),
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -86,7 +86,13 @@ describe("canonical turn membership", () => {
|
||||
assistant("heartbeat-2-reply", 6, "turn-3"),
|
||||
];
|
||||
|
||||
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
|
||||
const completed = layoutFor(items, false);
|
||||
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
||||
completedAt: at(6),
|
||||
durationMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -118,7 +124,6 @@ describe("canonical turn membership", () => {
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
startedAt: at(1),
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -107,7 +107,8 @@ import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||
import { PluginTimelineItemView } from "@/plugins/timeline";
|
||||
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -220,6 +221,22 @@ function renderListEmptyComponent(input: {
|
||||
);
|
||||
}
|
||||
|
||||
// History rows sit inside FlatList cells that rerender on every data change (RN recreates each
|
||||
// CellRenderer with a fresh ref and, in a newest-first list, a shifted index). This boundary is
|
||||
// what stops that churn: a row renders again only when its stream item identity, its layout item
|
||||
// identity, or the renderer itself changes. Item identity is the revision signal the strategy
|
||||
// already uses (`useRevisedHistoryRows` clones items whose content or display state changed).
|
||||
const HistoryStreamRow = memo(function HistoryStreamRow({
|
||||
layoutItem,
|
||||
renderStreamItem,
|
||||
}: {
|
||||
item: StreamItem;
|
||||
layoutItem: StreamLayoutItem;
|
||||
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
|
||||
}) {
|
||||
return <>{renderStreamItem(layoutItem)}</>;
|
||||
});
|
||||
|
||||
function renderHistoryStreamItem(input: {
|
||||
item: StreamItem;
|
||||
layoutItemById: Map<string, StreamLayoutItem>;
|
||||
@@ -229,7 +246,13 @@ function renderHistoryStreamItem(input: {
|
||||
if (!layoutItem) {
|
||||
return null;
|
||||
}
|
||||
return input.renderStreamItem(layoutItem);
|
||||
return (
|
||||
<HistoryStreamRow
|
||||
item={input.item}
|
||||
layoutItem={layoutItem}
|
||||
renderStreamItem={input.renderStreamItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
@@ -353,6 +376,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
@@ -535,6 +559,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
const projectedPlugins = useMemo(
|
||||
() => ({
|
||||
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
|
||||
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
|
||||
}),
|
||||
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
|
||||
);
|
||||
const {
|
||||
start: historyWindowStart,
|
||||
hasLocalHistory,
|
||||
@@ -542,7 +573,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: projectedToolCalls.tail,
|
||||
items: projectedPlugins.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
const isLoadingOlder = remoteIsLoadingOlder;
|
||||
@@ -553,8 +584,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||
tail: projectedToolCalls.tail,
|
||||
head: projectedToolCalls.head,
|
||||
tail: projectedPlugins.tail,
|
||||
head: projectedPlugins.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
historyStart: historyWindowStart,
|
||||
@@ -562,8 +593,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [
|
||||
isMobile,
|
||||
isTurnActive,
|
||||
projectedToolCalls.head,
|
||||
projectedToolCalls.tail,
|
||||
projectedPlugins.head,
|
||||
projectedPlugins.tail,
|
||||
effectiveTurnPresentation.startedAt,
|
||||
historyWindowStart,
|
||||
]);
|
||||
@@ -789,9 +820,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
// Read through a stable event so live group updates do not change the renderer identity
|
||||
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
|
||||
const getToolCallGroup = useStableEvent((hostId: string) =>
|
||||
projectedToolCalls.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
||||
const group = getToolCallGroup(item.id);
|
||||
if (!group) {
|
||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
@@ -818,8 +854,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
projectedToolCalls.groupsByHostId,
|
||||
expandedToolCallGroupIds,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
],
|
||||
|
||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import {
|
||||
KeyboardActionDispatcherProvider,
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -627,10 +627,9 @@ function SidebarChrome({
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
}) {
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||
);
|
||||
const active = visible && isOpen;
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>\n</svg>\n',
|
||||
"github-copilot-cli":
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="currentColor"/>\n<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="currentColor"/>\n<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="currentColor"/>\n</svg>\n',
|
||||
gjc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">\n <path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>\n <path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>\n <path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>\n <path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>\n <path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>\n</svg>\n',
|
||||
"glm-acp-agent":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
|
||||
goose:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">
|
||||
<path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>
|
||||
<path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>
|
||||
<path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -8,6 +8,7 @@ import {
|
||||
FolderPlus,
|
||||
History,
|
||||
Home,
|
||||
Import,
|
||||
Keyboard,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
@@ -17,6 +18,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
@@ -52,6 +54,7 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedImport = withUnistyles(Import, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -88,6 +91,10 @@ function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function ImportIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedImport size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function FolderIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedFolder size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
@@ -106,6 +113,7 @@ export function CommandCenterRootActions() {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
|
||||
@@ -166,11 +174,29 @@ export function CommandCenterRootActions() {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
id: "import-session",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 2,
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
keywords: ["import", "session", "terminal"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openImportSession();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("importSession.title"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: ImportIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 3,
|
||||
keywords: ["home", "start", "pair", "device", "providers"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
@@ -187,7 +213,7 @@ export function CommandCenterRootActions() {
|
||||
id: "history",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 3,
|
||||
rank: 4,
|
||||
keywords: ["history", "sessions", "recent"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -205,7 +231,7 @@ export function CommandCenterRootActions() {
|
||||
id: "schedules",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 4,
|
||||
rank: 5,
|
||||
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -223,7 +249,7 @@ export function CommandCenterRootActions() {
|
||||
id: "settings",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 5,
|
||||
rank: 6,
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -250,7 +276,7 @@ export function CommandCenterRootActions() {
|
||||
id: "toggle-left-sidebar",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 7,
|
||||
rank: 8,
|
||||
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
@@ -274,7 +300,7 @@ export function CommandCenterRootActions() {
|
||||
id: "keyboard-shortcuts",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 6,
|
||||
rank: 7,
|
||||
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
|
||||
visibility: "always",
|
||||
run: () => setShortcutsDialogOpen(true),
|
||||
@@ -309,6 +335,7 @@ export function CommandCenterRootActions() {
|
||||
homeRoute,
|
||||
keyboardActionDispatcher,
|
||||
openAddProject,
|
||||
openImportSession,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
@@ -322,5 +349,5 @@ export function CommandCenterRootActions() {
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
return importSessionSheet;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT,
|
||||
capAssistantMessageForRender,
|
||||
getUtf8ByteLength,
|
||||
} from "./assistant-message-render-limit";
|
||||
|
||||
describe("assistant message render limit", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("leaves messages at the limit unchanged", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({ text: message, capped: false });
|
||||
});
|
||||
|
||||
it("caps oversized messages before markdown rendering", () => {
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capAssistantMessageForRender(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not split a grapheme cluster at the boundary", () => {
|
||||
const prefix = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT - 1);
|
||||
|
||||
expect(capAssistantMessageForRender(`${prefix}étail`)).toEqual({
|
||||
text: prefix,
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the complete message size in UTF-8 bytes", () => {
|
||||
expect(getUtf8ByteLength("aé😀")).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps a bounded prefix when grapheme segmentation is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("Intl", { ...Intl, Segmenter: undefined });
|
||||
const { capAssistantMessageForRender: capWithoutSegmenter } =
|
||||
await import("./assistant-message-render-limit");
|
||||
const message = "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT + 1);
|
||||
|
||||
expect(capWithoutSegmenter(message)).toEqual({
|
||||
text: "a".repeat(ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT),
|
||||
capped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { clampToSafeRevealBoundary, isTextRevealPacingSupported } from "@/agent-stream/text-reveal";
|
||||
|
||||
export const ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT = 32_000;
|
||||
|
||||
export interface CappedAssistantMessage {
|
||||
text: string;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export function getUtf8ByteLength(message: string): number {
|
||||
let bytes = 0;
|
||||
|
||||
for (let index = 0; index < message.length; index += 1) {
|
||||
const codeUnit = message.charCodeAt(index);
|
||||
if (codeUnit < 0x80) {
|
||||
bytes += 1;
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2;
|
||||
} else if (
|
||||
codeUnit >= 0xd800 &&
|
||||
codeUnit <= 0xdbff &&
|
||||
index + 1 < message.length &&
|
||||
message.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
message.charCodeAt(index + 1) <= 0xdfff
|
||||
) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function capAssistantMessageForRender(message: string): CappedAssistantMessage {
|
||||
if (message.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT) {
|
||||
return { text: message, capped: false };
|
||||
}
|
||||
|
||||
let end = ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT;
|
||||
if (isTextRevealPacingSupported()) {
|
||||
end = clampToSafeRevealBoundary(message, end);
|
||||
} else {
|
||||
const finalCodeUnit = message.charCodeAt(end - 1);
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: message.slice(0, end), capped: true };
|
||||
}
|
||||
@@ -136,6 +136,8 @@ let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
dom.window.requestAnimationFrame = vi.fn(() => 1);
|
||||
dom.window.cancelAnimationFrame = vi.fn();
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("window", dom.window);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -33,23 +34,27 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !source) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
};
|
||||
}, [onClose, source]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
return true;
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
@@ -87,7 +92,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View style={styles.root}>
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
NativeExplorerSidebarDock,
|
||||
} from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerOpen) {
|
||||
if (enabled && isExplorerActive) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerOpen) {
|
||||
if (!isExplorerActive) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const isActive = useIsMobilePanelActive("file-explorer");
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
isOpen: isActive,
|
||||
});
|
||||
showMobileAgent();
|
||||
},
|
||||
[isOpen, showMobileAgent],
|
||||
[isActive, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={isOpen}>
|
||||
<RetainedPanelActivity active={isActive}>
|
||||
<MobilePanelOverlay
|
||||
panel="file-explorer"
|
||||
closeGesture={closeGesture}
|
||||
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
isOpen={isActive}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</MobilePanelOverlay>
|
||||
@@ -461,7 +462,6 @@ function ChangedFilesPane({
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
modeScope="compact-explorer"
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
state={changesState}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { formatDiffCount } from "@/git/file-header-presentation";
|
||||
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
@@ -7,15 +8,6 @@ interface DiffStatProps {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row} testID={testID}>
|
||||
|
||||
@@ -4,10 +4,16 @@ import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
collectProviderErrorRows,
|
||||
computeEmptyState,
|
||||
formatDirectoryLabel,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
hasMoreSessions,
|
||||
nextPageLimit,
|
||||
PER_PROVIDER_LIMIT,
|
||||
resolveDirectoryLabel,
|
||||
resolveImportTarget,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
type SessionsQueryResult,
|
||||
@@ -167,27 +173,176 @@ describe("sumFilteredAlreadyImportedCount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectErroredProviderLabels", () => {
|
||||
it("returns no labels when no providers are being fetched", () => {
|
||||
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
|
||||
describe("collectProviderErrorRows", () => {
|
||||
it("returns no rows when no providers are being fetched", () => {
|
||||
expect(collectProviderErrorRows(null, [], new Map())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns labels for each errored provider, falling back to provider id", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
it("returns a row for each provider whose request failed", () => {
|
||||
const rows = collectProviderErrorRows(
|
||||
["claude", "codex"],
|
||||
[settled(undefined, { isError: true }), settled({ entries: [] })],
|
||||
new Map([["claude", "Claude Code"]]),
|
||||
);
|
||||
expect(labels).toEqual(["Claude Code"]);
|
||||
expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]);
|
||||
});
|
||||
|
||||
it("returns a row for a provider the daemon reported as failed in a good response", () => {
|
||||
const rows = collectProviderErrorRows(
|
||||
["codex"],
|
||||
[
|
||||
settled({
|
||||
entries: [],
|
||||
providerErrors: [{ provider: "codex", message: "timed out" }],
|
||||
}),
|
||||
],
|
||||
new Map([["codex", "Codex"]]),
|
||||
);
|
||||
expect(rows).toEqual([{ provider: "codex", label: "Codex" }]);
|
||||
});
|
||||
|
||||
it("uses provider id when the label map has no entry", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
const rows = collectProviderErrorRows(
|
||||
["codex"],
|
||||
[settled(undefined, { isError: true })],
|
||||
new Map(),
|
||||
);
|
||||
expect(labels).toEqual(["codex"]);
|
||||
expect(rows).toEqual([{ provider: "codex", label: "codex" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPageLimit", () => {
|
||||
it("grows the per-provider limit one step at a time", () => {
|
||||
expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45);
|
||||
expect(nextPageLimit(45)).toBe(90);
|
||||
expect(nextPageLimit(90)).toBe(200);
|
||||
});
|
||||
|
||||
it("stops at the protocol ceiling", () => {
|
||||
expect(nextPageLimit(200)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMoreSessions", () => {
|
||||
it("reports more when a provider filled the requested page", () => {
|
||||
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports no more once every provider returned a short page", () => {
|
||||
expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no more at the last page size, however full the page is", () => {
|
||||
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps reporting more while the next page replaces placeholder rows", () => {
|
||||
expect(
|
||||
hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDirectoryLabel", () => {
|
||||
const projects = [
|
||||
{ rootPath: "/home/me/paseo", name: "paseo" },
|
||||
{ rootPath: "/home/me/paseo/packages/app", name: "paseo app" },
|
||||
];
|
||||
|
||||
it("names the project root after the project alone", () => {
|
||||
expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" });
|
||||
});
|
||||
|
||||
it("qualifies a worktree under the project with its path below the root", () => {
|
||||
expect(
|
||||
resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]),
|
||||
).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" });
|
||||
});
|
||||
|
||||
it("tells two worktrees of the same project apart", () => {
|
||||
const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]);
|
||||
const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]);
|
||||
expect(zebra).not.toEqual(otter);
|
||||
expect([zebra.detail, otter.detail]).toEqual([
|
||||
".dev/worktrees/abc/zebra",
|
||||
".dev/worktrees/def/otter",
|
||||
]);
|
||||
});
|
||||
|
||||
it("picks the most specific project root containing the directory", () => {
|
||||
expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({
|
||||
name: "paseo app",
|
||||
detail: "src",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores trailing slashes on both sides", () => {
|
||||
expect(
|
||||
resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]),
|
||||
).toEqual({ name: "p" });
|
||||
});
|
||||
|
||||
it("does not match a project root that is only a string prefix", () => {
|
||||
expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({
|
||||
name: "/home/me/paseo-fork",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the path when no project owns the directory", () => {
|
||||
expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDirectoryLabel", () => {
|
||||
it("shows the project name alone at its root", () => {
|
||||
expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo");
|
||||
});
|
||||
|
||||
it("appends the path under the root so worktrees of one project read apart", () => {
|
||||
expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe(
|
||||
"paseo · .dev/worktrees/zebra",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveImportTarget", () => {
|
||||
it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => {
|
||||
expect(
|
||||
resolveImportTarget({
|
||||
entryCwd: "/private/repo/paseo",
|
||||
workspaceCwd: "/repo/paseo",
|
||||
workspaceId: "ws-1",
|
||||
isScopedListing: true,
|
||||
}),
|
||||
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
|
||||
});
|
||||
|
||||
it("keeps the current workspace for a Show-all row in that workspace's directory", () => {
|
||||
expect(
|
||||
resolveImportTarget({
|
||||
entryCwd: "/repo/paseo/",
|
||||
workspaceCwd: "/repo/paseo",
|
||||
workspaceId: "ws-1",
|
||||
isScopedListing: false,
|
||||
}),
|
||||
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
|
||||
});
|
||||
|
||||
it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => {
|
||||
expect(
|
||||
resolveImportTarget({
|
||||
entryCwd: "/repo/other",
|
||||
workspaceCwd: "/repo/paseo",
|
||||
workspaceId: "ws-1",
|
||||
isScopedListing: false,
|
||||
}),
|
||||
).toEqual({ crossWorkspace: true });
|
||||
});
|
||||
|
||||
it("treats a sheet with no workspace as cross-workspace", () => {
|
||||
expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({
|
||||
crossWorkspace: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +391,7 @@ describe("computeEmptyState", () => {
|
||||
isQueryingProviders: true,
|
||||
allQueriesSettled: true,
|
||||
selectedProvider: ALL_FILTER_VALUE,
|
||||
hasQuery: false,
|
||||
aggregatedCount: 0,
|
||||
visibleCount: 0,
|
||||
totalAlreadyImportedCount: 0,
|
||||
@@ -274,6 +430,15 @@ describe("computeEmptyState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing matched when a query narrowed the list to nothing", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
hasQuery: true,
|
||||
totalAlreadyImportedCount: 4,
|
||||
});
|
||||
expect(result.emptyStateTitle).toBe("No sessions match your search.");
|
||||
});
|
||||
|
||||
it("shows the already-imported message when imported entries were filtered out", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
|
||||
@@ -5,6 +5,18 @@ import { i18n } from "@/i18n/i18next";
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
/**
|
||||
* Paging grows the per-provider limit instead of carrying an offset. The daemon
|
||||
* ranks the whole candidate set on every request, so an offset into a previous
|
||||
* ranking would drift as sessions move; refetching a larger page cannot. 200 is
|
||||
* the protocol ceiling on `limit`.
|
||||
*/
|
||||
const PAGE_LIMITS: readonly number[] = [PER_PROVIDER_LIMIT, 45, 90, 200];
|
||||
|
||||
export function nextPageLimit(limit: number): number {
|
||||
return PAGE_LIMITS.find((candidate) => candidate > limit) ?? limit;
|
||||
}
|
||||
|
||||
export function requiresImportSessionsHostUpgrade(input: {
|
||||
supportsSnapshot: boolean;
|
||||
workspaceId?: string | null;
|
||||
@@ -18,11 +30,13 @@ export interface SessionsQueryResult {
|
||||
| {
|
||||
entries: FetchRecentProviderSessionEntry[];
|
||||
filteredAlreadyImportedCount?: number;
|
||||
providerErrors?: Array<{ provider: string; message: string }>;
|
||||
}
|
||||
| undefined;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isPlaceholderData?: boolean;
|
||||
}
|
||||
|
||||
export function resolveProvidersToFetch(
|
||||
@@ -81,20 +95,132 @@ export function sumFilteredAlreadyImportedCount(
|
||||
return total;
|
||||
}
|
||||
|
||||
export function collectErroredProviderLabels(
|
||||
/**
|
||||
* A provider that returned a full page may have more behind it. The daemon
|
||||
* slices to `limit` after dropping already-imported sessions, so a short page is
|
||||
* the end of that provider's list. Placeholder rows belong to the previous page,
|
||||
* so the button holds its place instead of blinking out while the next loads.
|
||||
*/
|
||||
export function hasMoreSessions(
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
limit: number,
|
||||
): boolean {
|
||||
if (nextPageLimit(limit) === limit) return false;
|
||||
return queries.some((entryQuery) => {
|
||||
const count = entryQuery.data?.entries.length ?? 0;
|
||||
return entryQuery.isPlaceholderData ? count > 0 : count >= limit;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ProviderErrorRow {
|
||||
provider: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per provider that could not be listed, whether the request itself
|
||||
* failed or the daemon reported the provider as failed in an otherwise good
|
||||
* response. Both mean the same thing to the user, and both retry the same way.
|
||||
*/
|
||||
export function collectProviderErrorRows(
|
||||
providersToFetch: AgentProvider[] | null,
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
): ProviderErrorRow[] {
|
||||
if (providersToFetch === null) return [];
|
||||
const labels: string[] = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
if (queries[index]?.isError) {
|
||||
const provider = providersToFetch[index];
|
||||
labels.push(providerLabelById.get(provider) ?? provider);
|
||||
}
|
||||
const rows: ProviderErrorRow[] = [];
|
||||
for (let index = 0; index < providersToFetch.length; index++) {
|
||||
const query = queries[index];
|
||||
const provider = providersToFetch[index];
|
||||
if (!query || provider === undefined) continue;
|
||||
const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0;
|
||||
if (!failed) continue;
|
||||
rows.push({ provider, label: providerLabelById.get(provider) ?? provider });
|
||||
}
|
||||
return labels;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface DirectoryProject {
|
||||
rootPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DirectoryLabel {
|
||||
/** The owning project's name, or the whole path when no project owns it. */
|
||||
name: string;
|
||||
/**
|
||||
* The directory's path under its project root, absent at the root itself.
|
||||
* Worktrees of one project all resolve to the same name, so without this the
|
||||
* sheet would show several identical headings.
|
||||
*/
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function withoutTrailingSlash(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
||||
}
|
||||
|
||||
/** The project's name when the app knows the directory, else the directory itself. */
|
||||
export function resolveDirectoryLabel(
|
||||
directory: string,
|
||||
projects: ReadonlyArray<DirectoryProject>,
|
||||
): DirectoryLabel {
|
||||
const normalized = withoutTrailingSlash(directory);
|
||||
let bestRoot: string | null = null;
|
||||
let bestName: string | null = null;
|
||||
for (const project of projects) {
|
||||
const root = withoutTrailingSlash(project.rootPath);
|
||||
if (!root) continue;
|
||||
if (bestRoot !== null && root.length <= bestRoot.length) continue;
|
||||
if (normalized !== root && !normalized.startsWith(`${root}/`)) continue;
|
||||
bestRoot = root;
|
||||
bestName = project.name;
|
||||
}
|
||||
if (bestRoot === null || bestName === null) {
|
||||
return { name: normalized };
|
||||
}
|
||||
const relative = normalized.slice(bestRoot.length + 1);
|
||||
return relative ? { name: bestName, detail: relative } : { name: bestName };
|
||||
}
|
||||
|
||||
/** The one-line form a row shows under its prompt preview. */
|
||||
export function formatDirectoryLabel(label: DirectoryLabel): string {
|
||||
return label.detail ? `${label.name} · ${label.detail}` : label.name;
|
||||
}
|
||||
|
||||
export interface ImportTarget {
|
||||
/**
|
||||
* Omitted unless the row's directory is the scoped workspace's own: the daemon
|
||||
* rejects an import whose cwd does not match the requested workspace.
|
||||
*/
|
||||
workspaceId?: string;
|
||||
/**
|
||||
* The agent lands in a workspace other than the one the sheet was opened from,
|
||||
* so the caller has to open that workspace instead of adding a tab here.
|
||||
*/
|
||||
crossWorkspace: boolean;
|
||||
}
|
||||
|
||||
export function resolveImportTarget(input: {
|
||||
entryCwd: string;
|
||||
/** The directory the sheet was opened for, absent when it was opened host-wide. */
|
||||
workspaceCwd?: string | null;
|
||||
workspaceId?: string | null;
|
||||
/** The listed rows came from a fetch scoped to `workspaceCwd`. */
|
||||
isScopedListing: boolean;
|
||||
}): ImportTarget {
|
||||
if (!input.workspaceId || !input.workspaceCwd) {
|
||||
return { crossWorkspace: true };
|
||||
}
|
||||
// A scoped listing only holds rows the daemon already matched to this
|
||||
// workspace's directory with realpaths resolved, which the client cannot do.
|
||||
// "Show all" drops that guarantee, so each row is compared by path instead.
|
||||
const belongsToWorkspace =
|
||||
input.isScopedListing ||
|
||||
withoutTrailingSlash(input.entryCwd) === withoutTrailingSlash(input.workspaceCwd);
|
||||
return belongsToWorkspace
|
||||
? { workspaceId: input.workspaceId, crossWorkspace: false }
|
||||
: { crossWorkspace: true };
|
||||
}
|
||||
|
||||
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
|
||||
@@ -123,6 +249,7 @@ export interface EmptyStateInputs {
|
||||
isQueryingProviders: boolean;
|
||||
allQueriesSettled: boolean;
|
||||
selectedProvider: string;
|
||||
hasQuery: boolean;
|
||||
aggregatedCount: number;
|
||||
visibleCount: number;
|
||||
totalAlreadyImportedCount: number;
|
||||
@@ -142,6 +269,11 @@ export function computeEmptyState(input: EmptyStateInputs): {
|
||||
if (!showEmptyState) {
|
||||
return { showEmptyState, emptyStateTitle: "" };
|
||||
}
|
||||
// A query narrowing the list to nothing is a different answer from a host with
|
||||
// nothing to import, and the recovery is different too.
|
||||
if (input.hasQuery) {
|
||||
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noMatches") };
|
||||
}
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
|
||||
@@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6, lg: 8, full: 9999 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||
iconSize: { sm: 14, md: 16 },
|
||||
iconSize: { sm: 14, md: 16, lg: 24 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
@@ -30,6 +30,8 @@ const { theme } = vi.hoisted(() => ({
|
||||
surface3: "#333",
|
||||
border: "#444",
|
||||
borderAccent: "#555",
|
||||
interactionHighlight: "rgba(255,255,255,0.08)",
|
||||
palette: { red: { 300: "#f87171" } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -75,6 +77,8 @@ vi.mock("lucide-react-native", () => {
|
||||
Inbox: icon("Inbox"),
|
||||
Layers: icon("Layers"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
Search: icon("Search"),
|
||||
X: icon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,6 +121,22 @@ vi.mock("@/components/ui/combobox", () => ({
|
||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
|
||||
}));
|
||||
|
||||
interface SheetSearchProps {
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function SheetSearchInput({ search }: { search: SheetSearchProps }) {
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => search.onChange(event.target.value),
|
||||
[search],
|
||||
);
|
||||
return (
|
||||
<input data-testid={search.testID} placeholder={search.placeholder} onChange={handleChange} />
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
@@ -125,14 +145,21 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; actions?: ReactNode };
|
||||
header?: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
search?: { onChange: (value: string) => void; placeholder?: string; testID?: string };
|
||||
};
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.subtitle}
|
||||
{header?.actions}
|
||||
{header?.search ? <SheetSearchInput search={header.search} /> : null}
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
@@ -163,26 +190,62 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockHostFeatures = vi.hoisted(() => ({
|
||||
current: { importSessionSearch: true, importSessionWorkspaceTarget: true } as Record<
|
||||
string,
|
||||
boolean
|
||||
>,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: (_serverId: string | null, feature: string) =>
|
||||
mockHostFeatures.current[feature] === true,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHosts: () => [{ serverId: "server-1", label: "workbench" }],
|
||||
}));
|
||||
|
||||
const mockHostProjects = vi.hoisted(() => ({
|
||||
current: [] as Array<{ iconWorkingDir: string; projectName: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/projects/host-projects", () => ({
|
||||
useHostProjects: () => mockHostProjects.current,
|
||||
}));
|
||||
|
||||
interface RenderOptions {
|
||||
visible?: boolean;
|
||||
onClose?: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
||||
cwd?: string | null;
|
||||
workspaceId?: string;
|
||||
supportsSearch?: boolean;
|
||||
projects?: Array<{ iconWorkingDir: string; projectName: string }>;
|
||||
snapshot?: {
|
||||
entries?: ProviderSnapshotEntry[];
|
||||
supportsSnapshot?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||
options?: RenderOptions,
|
||||
) {
|
||||
function applyHostMocks(options?: RenderOptions) {
|
||||
mockSnapshot.current = {
|
||||
entries: options?.snapshot?.entries,
|
||||
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
|
||||
};
|
||||
mockHostFeatures.current = {
|
||||
importSessionSearch: options?.supportsSearch ?? true,
|
||||
importSessionWorkspaceTarget: true,
|
||||
};
|
||||
mockHostProjects.current = options?.projects ?? [];
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||
options?: RenderOptions,
|
||||
) {
|
||||
applyHostMocks(options);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -200,6 +263,7 @@ function renderSheet(
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
cwd={cwd}
|
||||
workspaceId={options?.workspaceId}
|
||||
onClose={options?.onClose ?? vi.fn()}
|
||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||
onImported={options?.onImported}
|
||||
@@ -245,6 +309,16 @@ function createImportedAgentSnapshot(id: string): Awaited<ReturnType<DaemonClien
|
||||
};
|
||||
}
|
||||
|
||||
function createPageOfEntries(count: number): FetchRecentProviderSessionEntry[] {
|
||||
return Array.from({ length: count }, (_unused, index) =>
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: `thread-${index}`,
|
||||
title: `Session ${index}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createProviderSessionEntry(
|
||||
overrides?: Partial<FetchRecentProviderSessionEntry>,
|
||||
): FetchRecentProviderSessionEntry {
|
||||
@@ -377,7 +451,8 @@ describe("ImportSessionSheet", () => {
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Could not load recent sessions.");
|
||||
await screen.findByText("Could not load Claude Code sessions");
|
||||
screen.getByTestId("import-session-retry-claude");
|
||||
});
|
||||
|
||||
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
|
||||
@@ -478,19 +553,34 @@ describe("ImportSessionSheet", () => {
|
||||
});
|
||||
|
||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
const events: string[] = [];
|
||||
let fetchCount = 0;
|
||||
const fetchRecentProviderSessions = vi.fn(async () => {
|
||||
fetchCount += 1;
|
||||
events.push("fetch");
|
||||
if (fetchCount > 1) {
|
||||
return await new Promise<never>(() => {});
|
||||
}
|
||||
return {
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
let resolveImport!: (agent: ReturnType<typeof createImportedAgentSnapshot>) => void;
|
||||
const importAgent = vi.fn(
|
||||
() =>
|
||||
new Promise<ReturnType<typeof createImportedAgentSnapshot>>((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onClose = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
);
|
||||
const onClose = vi.fn(() => events.push("close"));
|
||||
const onImportedAgent = vi.fn(() => events.push("navigate"));
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
@@ -500,21 +590,31 @@ describe("ImportSessionSheet", () => {
|
||||
{
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
workspaceId: "ws-current",
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
const row = await screen.findByTestId("import-session-session-claude-provider-thread-1");
|
||||
expect(events).toEqual(["fetch"]);
|
||||
fireEvent.click(row);
|
||||
|
||||
await screen.findByText("Importing...");
|
||||
expect(events).toEqual(["fetch"]);
|
||||
resolveImport(createImportedAgentSnapshot("agent-imported"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
workspaceId: "ws-current",
|
||||
});
|
||||
expect(events).toEqual(["fetch", "close", "navigate"]);
|
||||
});
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an import error state without closing when selected session import fails", async () => {
|
||||
@@ -649,7 +749,7 @@ describe("ImportSessionSheet", () => {
|
||||
);
|
||||
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Could not load sessions for Claude Code.");
|
||||
await screen.findByText("Could not load Claude Code sessions");
|
||||
});
|
||||
|
||||
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
|
||||
@@ -758,7 +858,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
|
||||
it("omits cwd from fetch and names each row's folder when cwd is unset", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
@@ -793,6 +893,9 @@ describe("ImportSessionSheet", () => {
|
||||
expect.objectContaining({ cwd: expect.anything() }),
|
||||
);
|
||||
await screen.findByText("/home/me/work/other-project");
|
||||
expect(
|
||||
screen.getByTestId("import-session-row-folder-claude-provider-thread-1").textContent,
|
||||
).toBe("/home/me/work/other-project");
|
||||
});
|
||||
|
||||
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
|
||||
@@ -836,10 +939,344 @@ describe("ImportSessionSheet", () => {
|
||||
});
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("names the scope after the host when unscoped and after the workspace when scoped", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent);
|
||||
|
||||
renderSheet(client, {
|
||||
cwd: null,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
});
|
||||
await screen.findByText("Sessions on workbench");
|
||||
expect(screen.queryByTestId("import-session-show-all")).toBeNull();
|
||||
cleanup();
|
||||
|
||||
renderSheet(client, {
|
||||
workspaceId: "ws-current",
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
});
|
||||
await screen.findByText("This workspace");
|
||||
screen.getByTestId("import-session-show-all");
|
||||
});
|
||||
|
||||
it("lists unscoped rows newest first and names each row's folder", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: "newest",
|
||||
cwd: "/home/me/paseo",
|
||||
title: "Newest in paseo",
|
||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
||||
}),
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: "elsewhere",
|
||||
cwd: "/tmp/scratch",
|
||||
title: "Scratch session",
|
||||
lastActivityAt: "2026-04-30T11:00:00.000Z",
|
||||
}),
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: "older",
|
||||
cwd: "/home/me/paseo",
|
||||
title: "Older in paseo",
|
||||
lastActivityAt: "2026-04-30T10:00:00.000Z",
|
||||
}),
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: "worktree",
|
||||
cwd: "/home/me/paseo/.dev/worktrees/zebra",
|
||||
title: "Worktree session",
|
||||
lastActivityAt: "2026-04-30T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
projects: [{ iconWorkingDir: "/home/me/paseo", projectName: "paseo" }],
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId("import-session-session-claude-newest");
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId(/^import-session-session-/)
|
||||
.map((row) => row.getAttribute("data-testid")),
|
||||
).toEqual([
|
||||
"import-session-session-claude-newest",
|
||||
"import-session-session-claude-elsewhere",
|
||||
"import-session-session-claude-older",
|
||||
"import-session-session-claude-worktree",
|
||||
]);
|
||||
expect(
|
||||
screen.getAllByTestId(/^import-session-row-folder-/).map((folder) => folder.textContent),
|
||||
).toEqual(["paseo", "/tmp/scratch", "paseo", "paseo · .dev/worktrees/zebra"]);
|
||||
});
|
||||
|
||||
it("leaves the folder off every row when the sheet is scoped to one workspace", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: "scoped",
|
||||
cwd: "/repo/paseo",
|
||||
title: "Scoped session",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
workspaceId: "ws-current",
|
||||
projects: [{ iconWorkingDir: "/repo/paseo", projectName: "paseo" }],
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Scoped session");
|
||||
expect(screen.queryAllByTestId(/^import-session-row-folder-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the debounced search query to every provider", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("import-session-search"), {
|
||||
target: { value: "invoice" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
query: "invoice",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the search field when the host cannot search sessions", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
supportsSearch: false,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId("import-session-search")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for a larger page when Load more is pressed, and stops offering it on a short page", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async (options: { limit?: number } | undefined) => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: createPageOfEntries(options?.limit === 15 ? 15 : 20),
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-load-more"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
providers: ["claude"],
|
||||
limit: 45,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("import-session-load-more")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the refresh spinner and retries only the failed provider", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => {
|
||||
const provider = options?.providers?.[0];
|
||||
if (provider === "claude") {
|
||||
throw new Error("claude offline");
|
||||
}
|
||||
return {
|
||||
requestId: `recent-${provider}`,
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: provider ?? "codex",
|
||||
providerHandleId: `${provider}-thread`,
|
||||
title: `Session ${provider}`,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Could not load Claude Code sessions");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("import-session-loading-spinner")).toBeNull();
|
||||
});
|
||||
|
||||
fetchRecentProviderSessions.mockClear();
|
||||
fireEvent.click(screen.getByTestId("import-session-retry-claude"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
});
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an error row for a provider the daemon reported as failed", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })],
|
||||
providerErrors: [{ provider: "codex", message: "timed out" }],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("codex")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Could not load Codex sessions");
|
||||
});
|
||||
|
||||
it("imports a foreign-directory row without the current workspace once Show all is on", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async (options: { cwd?: string } | undefined) => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerHandleId: options?.cwd ? "scoped-thread" : "foreign-thread",
|
||||
cwd: options?.cwd ?? "/home/me/work/other-project",
|
||||
title: options?.cwd ? "Scoped session" : "Foreign session",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onImported = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
workspaceId: "ws-current",
|
||||
onImported,
|
||||
onImportedAgent,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Scoped session");
|
||||
fireEvent.click(screen.getByTestId("import-session-show-all"));
|
||||
|
||||
await screen.findByText("Foreign session");
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-session-claude-foreign-thread"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "foreign-thread",
|
||||
cwd: "/home/me/work/other-project",
|
||||
});
|
||||
});
|
||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches sessions when the refresh button is clicked", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DaemonClient,
|
||||
@@ -10,22 +10,33 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
collectProviderErrorRows,
|
||||
computeEmptyState,
|
||||
type DirectoryProject,
|
||||
formatDirectoryLabel,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
hasMoreSessions,
|
||||
resolveDirectoryLabel,
|
||||
nextPageLimit,
|
||||
PER_PROVIDER_LIMIT,
|
||||
type ProviderErrorRow,
|
||||
resolveImportTarget,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
@@ -33,6 +44,8 @@ import {
|
||||
|
||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||
/** Long enough that a typed word is one request, short enough to feel live. */
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
|
||||
type RecentProviderSessionsClient = Pick<
|
||||
DaemonClient,
|
||||
@@ -48,7 +61,9 @@ interface ImportSessionSheetProps {
|
||||
cwd?: string | null;
|
||||
workspaceId?: string | null;
|
||||
onClose: () => void;
|
||||
/** The agent belongs to the workspace the sheet was opened from; open it here. */
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
/** The agent belongs to its own workspace; open that workspace and navigate. */
|
||||
onImported?: (agent: ImportedAgent) => void;
|
||||
}
|
||||
|
||||
@@ -56,27 +71,51 @@ type RecentSessionsResponse = Awaited<
|
||||
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
|
||||
>;
|
||||
|
||||
type SessionsQueryKey = ReadonlyArray<string | number | null>;
|
||||
|
||||
function buildSessionsQueryKey(input: {
|
||||
cwd: string | null;
|
||||
query: string;
|
||||
limit: number;
|
||||
provider?: string;
|
||||
}): SessionsQueryKey {
|
||||
return [
|
||||
"recent-provider-sessions",
|
||||
input.cwd,
|
||||
input.query,
|
||||
input.limit,
|
||||
...(input.provider === undefined ? [] : [input.provider]),
|
||||
];
|
||||
}
|
||||
|
||||
interface SessionsQueryConfig {
|
||||
queryKey: ReadonlyArray<string | null>;
|
||||
queryKey: SessionsQueryKey;
|
||||
enabled: boolean;
|
||||
// A provider that cannot answer stays broken until the user asks again. React
|
||||
// Query's default retry would keep the header spinner turning and pile up
|
||||
// in-flight daemon requests behind a dead provider (#2512).
|
||||
retry: false;
|
||||
placeholderData: typeof keepPreviousData;
|
||||
queryFn: () => Promise<RecentSessionsResponse>;
|
||||
}
|
||||
|
||||
function buildSessionsQueriesConfig(args: {
|
||||
providersToFetch: AgentProvider[] | null;
|
||||
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
cwd: string | null | undefined;
|
||||
cwd: string | null;
|
||||
query: string;
|
||||
limit: number;
|
||||
hostDisconnectedMessage?: string;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||
args;
|
||||
const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
queryKey: [...sessionsQueryRoot, provider],
|
||||
queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }),
|
||||
enabled,
|
||||
retry: false as const,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
@@ -84,7 +123,8 @@ function buildSessionsQueriesConfig(args: {
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(cwd ? { cwd } : {}),
|
||||
providers: [provider],
|
||||
limit: PER_PROVIDER_LIMIT,
|
||||
limit,
|
||||
...(query ? { query } : {}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -96,8 +136,6 @@ interface SheetStatusMessagesProps {
|
||||
hasNoImportableProviders: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
hasRows: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
erroredProviderLabels: ReadonlyArray<string>;
|
||||
importErrored: boolean;
|
||||
}
|
||||
|
||||
@@ -107,8 +145,6 @@ function SheetStatusMessages({
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -130,16 +166,6 @@ function SheetStatusMessages({
|
||||
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{allQueriesErrored ? (
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
|
||||
) : null}
|
||||
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
|
||||
<Text style={styles.statusText}>
|
||||
{t("importSession.status.failedProviders", {
|
||||
providers: erroredProviderLabels.join(", "),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{importErrored ? (
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
||||
) : null}
|
||||
@@ -147,6 +173,48 @@ function SheetStatusMessages({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderErrorBanner({
|
||||
rows,
|
||||
onRetry,
|
||||
}: {
|
||||
rows: ReadonlyArray<ProviderErrorRow>;
|
||||
onRetry: (provider: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.errorBanner} testID="import-session-provider-errors">
|
||||
{rows.map((row) => (
|
||||
<ProviderErrorBannerRow key={row.provider} row={row} onRetry={onRetry} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderErrorBannerRow({
|
||||
row,
|
||||
onRetry,
|
||||
}: {
|
||||
row: ProviderErrorRow;
|
||||
onRetry: (provider: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => onRetry(row.provider), [onRetry, row.provider]);
|
||||
return (
|
||||
<View style={styles.errorRow}>
|
||||
<Text style={styles.errorText}>
|
||||
{t("importSession.status.failedProvider", { provider: row.label })}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onPress={handleRetry}
|
||||
testID={`import-session-retry-${row.provider}`}
|
||||
>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -177,6 +245,38 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeSubtitle({
|
||||
hostLabel,
|
||||
isScoped,
|
||||
onShowAll,
|
||||
}: {
|
||||
hostLabel: string;
|
||||
isScoped: boolean;
|
||||
onShowAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.subtitleRow}>
|
||||
<Text style={styles.subtitleText} numberOfLines={1} testID="import-session-scope">
|
||||
{isScoped
|
||||
? t("importSession.scope.workspace")
|
||||
: t("importSession.scope.host", { host: hostLabel })}
|
||||
</Text>
|
||||
{isScoped ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onPress={onShowAll}
|
||||
testID="import-session-show-all"
|
||||
style={styles.subtitleAction}
|
||||
>
|
||||
{t("importSession.actions.showAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetEmptyState({ title }: { title: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
@@ -193,13 +293,14 @@ function ImportSessionSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
showCwd,
|
||||
folder,
|
||||
onImportSession,
|
||||
}: {
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: boolean;
|
||||
showCwd: boolean;
|
||||
/** The row's directory, shown only when rows can come from more than one. */
|
||||
folder: string | null;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -248,9 +349,13 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
</Text>
|
||||
{showCwd && entry.cwd ? (
|
||||
<Text style={styles.rowCwd} numberOfLines={1}>
|
||||
{entry.cwd}
|
||||
{folder ? (
|
||||
<Text
|
||||
style={styles.rowFolder}
|
||||
numberOfLines={1}
|
||||
testID={`import-session-row-folder-${entry.providerId}-${entry.providerHandleId}`}
|
||||
>
|
||||
{folder}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -258,6 +363,35 @@ function ImportSessionSheetRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
entries,
|
||||
disabled,
|
||||
importingSessionKey,
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
importingSessionKey: string | null;
|
||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{entries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
@@ -272,8 +406,32 @@ export function ImportSessionSheet({
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
// "Show all" widens a workspace-scoped sheet to the whole host. The sheet's own
|
||||
// `cwd` stays the scope it was opened with, because that is what decides where
|
||||
// an imported agent lands.
|
||||
const [isShowingAllDirectories, setIsShowingAllDirectories] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [pageLimit, setPageLimit] = useState(PER_PROVIDER_LIMIT);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
|
||||
const scopeCwd = isShowingAllDirectories ? null : (cwd ?? null);
|
||||
const supportsSearch = useHostFeature(serverId, "importSessionSearch");
|
||||
const query = useDebouncedValue(supportsSearch ? searchInput : "", SEARCH_DEBOUNCE_MS).trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
setIsShowingAllDirectories(false);
|
||||
setSearchInput("");
|
||||
}, [visible]);
|
||||
|
||||
// A narrower or wider list starts at page one; keeping a grown limit would
|
||||
// fetch 200 rows for a query that matches three.
|
||||
useEffect(() => {
|
||||
setPageLimit(PER_PROVIDER_LIMIT);
|
||||
}, [query, scopeCwd]);
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
cwd,
|
||||
cwd: scopeCwd,
|
||||
enabled: visible,
|
||||
});
|
||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||
@@ -293,22 +451,20 @@ export function ImportSessionSheet({
|
||||
[snapshotEntries],
|
||||
);
|
||||
|
||||
const sessionsQueryRoot = useMemo(
|
||||
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||
[cwd],
|
||||
);
|
||||
const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
() =>
|
||||
buildSessionsQueriesConfig({
|
||||
providersToFetch,
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
cwd: scopeCwd,
|
||||
query,
|
||||
limit: pageLimit,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||
[providersToFetch, visible, client, scopeCwd, query, pageLimit, t],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
@@ -320,8 +476,6 @@ export function ImportSessionSheet({
|
||||
);
|
||||
|
||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
@@ -339,6 +493,27 @@ export function ImportSessionSheet({
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||
}, [aggregatedEntries, selectedProvider]);
|
||||
|
||||
const projectServerIds = useMemo(() => (serverId ? [serverId] : []), [serverId]);
|
||||
const hostProjects = useHostProjects(projectServerIds);
|
||||
const directoryProjects = useMemo<DirectoryProject[]>(
|
||||
() =>
|
||||
hostProjects.map((project) => ({
|
||||
rootPath: project.iconWorkingDir,
|
||||
name: project.projectName,
|
||||
})),
|
||||
[hostProjects],
|
||||
);
|
||||
|
||||
// A scoped sheet only lists one directory, so naming it on every row is noise.
|
||||
const showRowFolders = scopeCwd === null;
|
||||
const resolveFolder = useCallback(
|
||||
(entry: FetchRecentProviderSessionEntry) =>
|
||||
showRowFolders
|
||||
? formatDirectoryLabel(resolveDirectoryLabel(entry.cwd, directoryProjects))
|
||||
: null,
|
||||
[showRowFolders, directoryProjects],
|
||||
);
|
||||
|
||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
||||
@@ -414,19 +589,31 @@ export function ImportSessionSheet({
|
||||
if (!entry.cwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
}
|
||||
const target = resolveImportTarget({
|
||||
entryCwd: entry.cwd,
|
||||
workspaceCwd: cwd,
|
||||
workspaceId,
|
||||
isScopedListing: scopeCwd !== null,
|
||||
});
|
||||
const agent = await client.importAgent({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: entry.cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(target.workspaceId ? { workspaceId: target.workspaceId } : {}),
|
||||
});
|
||||
return agent;
|
||||
return { agent, target };
|
||||
},
|
||||
onSuccess: async (agent) => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
onSuccess: ({ agent, target }) => {
|
||||
onClose();
|
||||
onImportedAgent?.(agent.id);
|
||||
onImported?.(agent);
|
||||
if (target.crossWorkspace) {
|
||||
onImported?.(agent);
|
||||
} else {
|
||||
onImportedAgent?.(agent.id);
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: sessionsQueryRoot,
|
||||
refetchType: "none",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -442,23 +629,61 @@ export function ImportSessionSheet({
|
||||
[importMutation],
|
||||
);
|
||||
|
||||
const erroredProviderLabels = useMemo(
|
||||
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
|
||||
const providerErrorRows = useMemo(
|
||||
() => collectProviderErrorRows(providersToFetch, queries, providerLabelById),
|
||||
[queries, providersToFetch, providerLabelById],
|
||||
);
|
||||
|
||||
const isRefreshing = queries.some((query) => query.isFetching);
|
||||
// Every query settles, errors included, so this stops turning (#2512).
|
||||
const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
}, [queryClient, sessionsQueryRoot]);
|
||||
|
||||
const handleRetryProvider = useCallback(
|
||||
(provider: string) => {
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: buildSessionsQueryKey({ cwd: scopeCwd, query, limit: pageLimit, provider }),
|
||||
});
|
||||
},
|
||||
[pageLimit, query, queryClient, scopeCwd],
|
||||
);
|
||||
|
||||
const handleShowAll = useCallback(() => setIsShowingAllDirectories(true), []);
|
||||
const handleLoadMore = useCallback(() => setPageLimit(nextPageLimit), []);
|
||||
|
||||
const hosts = useHosts();
|
||||
const hostLabel = useMemo(
|
||||
() => hosts.find((host) => host.serverId === serverId)?.label ?? serverId ?? "",
|
||||
[hosts, serverId],
|
||||
);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("importSession.title"),
|
||||
subtitle: (
|
||||
<ScopeSubtitle
|
||||
hostLabel={hostLabel}
|
||||
isScoped={scopeCwd !== null}
|
||||
onShowAll={handleShowAll}
|
||||
/>
|
||||
),
|
||||
...(supportsSearch
|
||||
? {
|
||||
search: {
|
||||
onChange: setSearchInput,
|
||||
placeholder: t("importSession.searchPlaceholder"),
|
||||
// The compact sheet keeps its content mounted while hidden, so the
|
||||
// field has to be told to drop the text the state already dropped.
|
||||
resetKey: visible ? "open" : "closed",
|
||||
testID: "import-session-search",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||
}),
|
||||
[isRefreshing, handleRefresh, t],
|
||||
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = requiresHostUpgrade;
|
||||
@@ -467,22 +692,27 @@ export function ImportSessionSheet({
|
||||
const isQueryingProviders = queries.length > 0;
|
||||
const isLoadingSessions =
|
||||
isWaitingForSnapshot ||
|
||||
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
|
||||
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
|
||||
(isQueryingProviders &&
|
||||
queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending));
|
||||
const allQueriesErrored =
|
||||
isQueryingProviders && queries.every((providerQuery) => providerQuery.isError);
|
||||
const allQueriesSettled =
|
||||
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
|
||||
isQueryingProviders &&
|
||||
queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending);
|
||||
const { showEmptyState, emptyStateTitle } = computeEmptyState({
|
||||
isLoadingSessions,
|
||||
allQueriesErrored,
|
||||
isQueryingProviders,
|
||||
allQueriesSettled,
|
||||
selectedProvider,
|
||||
hasQuery: query.length > 0,
|
||||
aggregatedCount: aggregatedEntries.length,
|
||||
visibleCount: visibleEntries.length,
|
||||
totalAlreadyImportedCount,
|
||||
providerLabelById,
|
||||
});
|
||||
const showFilter = filterProviders.length > 1;
|
||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -536,22 +766,30 @@ export function ImportSessionSheet({
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
{visibleEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={importMutation.isPending}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
showCwd={!cwd}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
))}
|
||||
<SessionRows
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
) : null}
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
@@ -560,6 +798,21 @@ export function ImportSessionSheet({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
subtitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
subtitleText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
subtitleAction: {
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
},
|
||||
filterTriggerWrap: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
@@ -568,6 +821,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
maxWidth: "100%",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -582,6 +836,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
@@ -589,6 +845,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
@@ -636,7 +896,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
rowCwd: {
|
||||
rowFolder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
@@ -650,6 +910,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorBanner: {
|
||||
marginBottom: theme.spacing[2],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
errorRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
errorText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user