mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b7ef823a |
@@ -20,50 +20,7 @@ env:
|
|||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
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:
|
publish-android-apk:
|
||||||
needs: [ensure-release]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -79,6 +36,32 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
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
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -146,5 +129,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||||
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||||
else
|
else
|
||||||
prerelease_flag=""
|
prerelease_flag=""
|
||||||
@@ -86,10 +86,8 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--notes "" \
|
--notes "" \
|
||||||
--draft \
|
|
||||||
$prerelease_flag || {
|
$prerelease_flag || {
|
||||||
echo "Release creation raced with another workflow; continuing."
|
echo "Release creation raced with another workflow; continuing."
|
||||||
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
|
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -193,8 +191,7 @@ jobs:
|
|||||||
echo "::error::No release artifacts found in $release_dir"
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -302,8 +299,7 @@ jobs:
|
|||||||
echo "::error::No release artifacts found in $release_dir"
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -408,8 +404,7 @@ jobs:
|
|||||||
echo "::error::No release artifacts found in $release_dir"
|
echo "::error::No release artifacts found in $release_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
|
||||||
|
|
||||||
- name: Upload desktop artifacts to workflow
|
- name: Upload desktop artifacts to workflow
|
||||||
if: env.SHOULD_PUBLISH != 'true'
|
if: env.SHOULD_PUBLISH != 'true'
|
||||||
@@ -429,7 +424,7 @@ jobs:
|
|||||||
|
|
||||||
finalize-rollout:
|
finalize-rollout:
|
||||||
needs: [publish-macos, publish-linux, publish-windows]
|
needs: [publish-macos, publish-linux, publish-windows]
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
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') }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -485,7 +480,7 @@ jobs:
|
|||||||
name: windows-manifest
|
name: windows-manifest
|
||||||
path: release-manifests/windows-manifest
|
path: release-manifests/windows-manifest
|
||||||
|
|
||||||
- name: Assemble manifests and publish release
|
- name: Assemble and upload stamped manifests
|
||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -496,33 +491,6 @@ jobs:
|
|||||||
manifests_dir="$PWD/final"
|
manifests_dir="$PWD/final"
|
||||||
mkdir -p "$manifests_dir"
|
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
|
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
||||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||||
node ../scripts/merge-mac-manifest.mjs \
|
node ../scripts/merge-mac-manifest.mjs \
|
||||||
@@ -547,7 +515,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
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 '
|
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -569,28 +536,4 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
' "${files[@]}"
|
' "${files[@]}"
|
||||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
gh release upload "$RELEASE_TAG" "${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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
create_if_missing:
|
create_if_missing:
|
||||||
description: "Create a draft release if missing (normally only needed for tag events)."
|
description: "Create release if missing (normally only needed for tag events)."
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -29,6 +29,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
+1
-64
@@ -1,93 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.7.2 - 2026-09-02
|
## 0.7.0-beta.1 - 2026-08-27
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
- Changed the project license to Apache-2.0 ([#3944](https://github.com/getpaseo/paseo/pull/3944))
|
- 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 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
|
||||||
|
|
||||||
- Added plugin installation and updates directly from Git repositories ([#3920](https://github.com/getpaseo/paseo/pull/3920))
|
- 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 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 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 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 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 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 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
|
### 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))
|
- 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))
|
- 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))
|
- 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
|
||||||
|
|
||||||
- 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 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 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))
|
- Fixed native plugin setup and async client callbacks failing on iOS ([#3942](https://github.com/getpaseo/paseo/pull/3942))
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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/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/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/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/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/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 |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
|
|||||||
@@ -31,15 +31,6 @@ 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.
|
- **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.
|
- **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.
|
- **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
|
## Measuring
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ 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.
|
- `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
|
- `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/`
|
- 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 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.
|
- 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)
|
- Voice features: dictation (STT) and voice agent (realtime)
|
||||||
@@ -355,13 +356,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
|
|||||||
|
|
||||||
| State | Key builder / store | Source |
|
| State | Key builder / store | Source |
|
||||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
|
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
|
||||||
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
|
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
|
||||||
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.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 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` |
|
| 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/comparison/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/override/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.
|
**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. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines.
|
`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.
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -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`.
|
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 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.
|
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.
|
||||||
|
|
||||||
Weight has three tiers, applied by role:
|
Weight has three tiers, applied by role:
|
||||||
|
|
||||||
@@ -120,8 +120,6 @@ 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.
|
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.
|
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.
|
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.
|
||||||
@@ -250,7 +248,6 @@ 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.
|
- `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.
|
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` 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.
|
- 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".
|
- 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.
|
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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.
|
- **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.
|
- **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.
|
- **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).
|
- **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".
|
- **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).
|
- **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).
|
||||||
|
|||||||
+6
-10
@@ -17,21 +17,17 @@ 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
|
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.
|
WebSocket without pairing again. The current foundation supports one Hub relationship per daemon.
|
||||||
|
|
||||||
Normal authenticated daemon sessions may manage the daemon's Hub relationship and permissions.
|
Normal authenticated daemon sessions may run the `hub.management.daemon.connect`,
|
||||||
Hub connections have no daemon permissions by default. Connecting gives Hub machine identity and
|
`hub.management.daemon.get_status`, and `hub.management.daemon.disconnect` RPCs. Hub connections
|
||||||
presence but no execution authority. The `hub.execute` permission lets workflows triggered from
|
receive only `hub.execution.*` authority, so execution credentials cannot manage the relationship.
|
||||||
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
|
## Session grants and execution ownership
|
||||||
|
|
||||||
Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
|
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
|
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
|
||||||
connection receives its persisted permissions. Legacy relationship files carrying
|
connection receives `hub.execute`. The current enrollment and persistence format still carries
|
||||||
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
|
`hub.execution.*`; one compatibility boundary translates it before the session is created. A denied
|
||||||
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).
|
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,
|
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
|
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
|
||||||
|
|||||||
+10
-21
@@ -30,10 +30,8 @@ The UI worklet owns transient motion:
|
|||||||
- the active gesture's starting revision
|
- the active gesture's starting revision
|
||||||
- the last settled target
|
- the last settled target
|
||||||
|
|
||||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
|
||||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
receive pointer events. Worklets never own `display` or `pointerEvents`.
|
||||||
The gesture hosts stay mounted; worklets reveal their retained overlays through native styles while
|
|
||||||
React owns pointer events and accessibility.
|
|
||||||
|
|
||||||
## Why one position
|
## Why one position
|
||||||
|
|
||||||
@@ -57,10 +55,8 @@ while that revision still owns the gesture.
|
|||||||
|
|
||||||
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
|
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.
|
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
|
||||||
Canceled gestures return to the latest canonical target. A successful gesture starts its finishing
|
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
|
||||||
motion immediately; the matching React command adopts that motion instead of restarting it. Position
|
its target and revision still match the canonical command.
|
||||||
settlement and command acceptance may arrive in either order. Activity changes after both agree on
|
|
||||||
the same target.
|
|
||||||
|
|
||||||
Manual gesture arbitration has two phases:
|
Manual gesture arbitration has two phases:
|
||||||
|
|
||||||
@@ -89,14 +85,8 @@ definition, no longer eligible to begin.
|
|||||||
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
|
- 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
|
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
|
||||||
has caused native crashes.
|
has caused native crashes.
|
||||||
- `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` stays off in `packages/app/package.json`. It is a
|
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
|
||||||
compile-time flag; changing it needs a native rebuild. With it on, Reanimated 4.3 hands settled
|
prop commit from resurrecting a closed overlay.
|
||||||
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,
|
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
|
||||||
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
pointer events, and the active signal consumed by `useRetainedPanelActive`.
|
||||||
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
|
||||||
@@ -108,9 +98,8 @@ definition, no longer eligible to begin.
|
|||||||
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
|
- 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
|
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
|
||||||
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
|
||||||
- `useIsMobilePanelActive` follows the settled target, not the requested target. Position at the
|
- The newly active panel must be included in the same render that changes selection. Adding it from an
|
||||||
canonical target's anchor is the settlement signal; animation duration and completion callbacks
|
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
|
||||||
do not own activity. Cancellation publishes nothing.
|
|
||||||
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
|
- 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,
|
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.
|
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
|
||||||
@@ -118,5 +107,5 @@ definition, no longer eligible to begin.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
|
||||||
rapid-command, position-settlement, and width-projection sequences through the transition model. Add
|
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
|
||||||
a sequence there whenever ownership or ordering changes.
|
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
+28
-98
@@ -1,11 +1,9 @@
|
|||||||
# Plugins
|
# Plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources
|
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||||
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
|
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||||
contribution in the app runtime.
|
Paseo does not sandbox it.
|
||||||
|
|
||||||
> **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
|
## Install a directory source
|
||||||
|
|
||||||
@@ -49,13 +47,15 @@ my-plugin/
|
|||||||
paseo-plugin.json
|
paseo-plugin.json
|
||||||
index.ts
|
index.ts
|
||||||
main.client.tsx
|
main.client.tsx
|
||||||
|
paseo-plugin.d.ts
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||||
dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the
|
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||||
runtime modules, so consumers do not install these packages when adding the plugin.
|
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||||
|
SDK contract changes.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -84,52 +84,28 @@ directory always wins over shorthand resolution.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo plugin add owner/repository
|
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 https://git.example.com/owner/repository.git
|
||||||
paseo plugin add owner/monorepo:plugins/review
|
paseo plugin add owner/monorepo --path plugins/review
|
||||||
paseo plugin add owner/repository --ref main
|
paseo plugin add owner/repository --ref main
|
||||||
paseo plugin status
|
paseo plugin status
|
||||||
paseo plugin update review
|
paseo plugin update review
|
||||||
paseo plugin update --all
|
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;
|
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
|
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
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.
|
||||||
|
|
||||||
### Declare Git preparation
|
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
|
||||||
Most plugins should omit `build`. Use it only when the staged checkout must install a dependency
|
dependencies work only when the Paseo app already ships the native module.
|
||||||
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]`
|
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
|
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
|
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>`. Git preparation commands are
|
tail from the host plugin settings or with `paseo plugin logs <id>`. Reload, disable, and process
|
||||||
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
|
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
|
structured copies remain in `$PASEO_HOME/daemon.log`. Plugin output can contain secrets, so do not
|
||||||
log credentials or tokens.
|
log credentials or tokens.
|
||||||
@@ -210,13 +186,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
|
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.
|
query cache. Plugin code cannot address another host.
|
||||||
|
|
||||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
Workspace panels and Command Center items remain client contributions. The daemon transports their
|
||||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
|
||||||
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
|
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
|
||||||
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
|
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
|
||||||
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
|
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
|
||||||
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
|
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
|
||||||
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
|
app state or a memoized selection. Panels use one persisted
|
||||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
`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.
|
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||||
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||||
@@ -276,66 +252,20 @@ the host, or unloading the app tears down the contribution.
|
|||||||
## Contribute timeline items
|
## Contribute timeline items
|
||||||
|
|
||||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||||
built-in projection stay unchanged. The app transforms each source item while building the render
|
built-in projection stay unchanged. The app transforms fetched projected history before building
|
||||||
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
|
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
|
||||||
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
|
collapsed before the transformer replaces anything.
|
||||||
streaming updates preserve mounted component identity.
|
|
||||||
|
|
||||||
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
|
`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`
|
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
|
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 and identity, validates renderer data with
|
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
|
||||||
its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An
|
schema, and mounts the component inside the normal plugin runtime and error boundary.
|
||||||
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
|
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.
|
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.
|
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. The row stays
|
|
||||||
in canonical history and renders an unavailable placeholder when its renderer is not installed.
|
|
||||||
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
|
|
||||||
|
|
||||||
`addClientSlashCommand` 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
|
|
||||||
plugin.addClientSlashCommand({
|
|
||||||
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
|
## Contribute composer attachments
|
||||||
|
|
||||||
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
|
||||||
|
|||||||
+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 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 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 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 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`.
|
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.
|
`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. 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.
|
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.
|
||||||
|
|
||||||
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.
|
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()`.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
+7
-30
@@ -112,7 +112,7 @@ npm run release:patch
|
|||||||
npm run release:minor
|
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. 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`.
|
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`.
|
||||||
|
|
||||||
After the stable release succeeds, move npm's `beta` pointer to the new stable
|
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
|
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:
|
Desktop release builds now publish in two phases:
|
||||||
|
|
||||||
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
|
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
|
||||||
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes 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`.
|
||||||
|
|
||||||
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.
|
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
|
||||||
|
|
||||||
### Default behavior
|
### 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 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 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 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 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.
|
- 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/**`.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
|
|
||||||
@@ -404,13 +404,6 @@ 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
|
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
|
||||||
you actually want.
|
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
|
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
|
||||||
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
|
||||||
image.
|
image.
|
||||||
@@ -440,22 +433,6 @@ 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
|
- `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
|
- `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
|
## Notes
|
||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
@@ -608,7 +585,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
|
- [ ] `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
|
- [ ] Every GitHub Actions run for the complete release commit and tag is green
|
||||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||||
- [ ] 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
|
- [ ] The GitHub prerelease exists with 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
|
- [ ] 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`
|
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
@@ -631,7 +608,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
|
- [ ] `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
|
- [ ] 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
|
- [ ] 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 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
|
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Added
|
|
||||||
- Added Claude Fable 5.1 with a 1M context window
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Added
|
|
||||||
- Added Claude Fable 5.1 with a 1M context window
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Added
|
|
||||||
- Added Claude Fable 5.1 with a 1M context window
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
Added
|
|
||||||
- Added Claude Fable 5.1 with a 1M context window
|
|
||||||
|
|
||||||
Full notes: https://paseo.sh/changelog
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,11 +0,0 @@
|
|||||||
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,11 +0,0 @@
|
|||||||
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,11 +0,0 @@
|
|||||||
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-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
|
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||||
|
|||||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36298,7 +36298,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37363,12 +37363,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.7.2",
|
"@getpaseo/client": "0.7.0-beta.1",
|
||||||
"@getpaseo/protocol": "0.7.2",
|
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||||
"@getpaseo/server": "0.7.2",
|
"@getpaseo/server": "0.7.0-beta.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37615,10 +37615,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.7.2",
|
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||||
"@getpaseo/relay": "0.7.2",
|
"@getpaseo/relay": "0.7.0-beta.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37629,7 +37629,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37872,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38768,7 +38768,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38797,13 +38797,13 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.7.2",
|
"@getpaseo/client": "0.7.0-beta.1",
|
||||||
"@getpaseo/protocol": "0.7.2",
|
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38812,15 +38812,15 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.7.2",
|
"@getpaseo/client": "0.7.0-beta.1",
|
||||||
"@getpaseo/protocol": "0.7.2",
|
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38833,7 +38833,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39051,17 +39051,17 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.2",
|
"@getpaseo/client": "0.7.0-beta.1",
|
||||||
"@getpaseo/highlight": "0.7.2",
|
"@getpaseo/highlight": "0.7.0-beta.1",
|
||||||
"@getpaseo/plugin": "0.7.2",
|
"@getpaseo/plugin": "0.7.0-beta.1",
|
||||||
"@getpaseo/protocol": "0.7.2",
|
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||||
"@getpaseo/relay": "0.7.2",
|
"@getpaseo/relay": "0.7.0-beta.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39985,7 +39985,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ import {
|
|||||||
expectResumeOverflowFallsBackToOneTail,
|
expectResumeOverflowFallsBackToOneTail,
|
||||||
rememberTimelineRequestCounts,
|
rememberTimelineRequestCounts,
|
||||||
} from "../support/helpers/timeline-resume";
|
} from "../support/helpers/timeline-resume";
|
||||||
import {
|
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||||
waitForWorkspaceInSidebar,
|
|
||||||
workspaceDeckEntryLocator,
|
|
||||||
} from "../support/helpers/workspace-ui";
|
|
||||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||||
import {
|
import {
|
||||||
scrollTimelineToNewestLoadedEdge,
|
scrollTimelineToNewestLoadedEdge,
|
||||||
@@ -471,10 +468,6 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
|||||||
|
|
||||||
await target.client.waitForFinish(target.agentId, 30_000);
|
await target.client.waitForFinish(target.agentId, 30_000);
|
||||||
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
|
||||||
await waitForWorkspaceInSidebar(page, {
|
|
||||||
serverId: getServerId(),
|
|
||||||
workspaceId: target.workspaceId,
|
|
||||||
});
|
|
||||||
await openAgentRoute(page, target);
|
await openAgentRoute(page, target);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
await subscriptions.waitForSubscribedAgents([target.agentId]);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
switchAwayAndBackWithoutImageInstability,
|
switchAwayAndBackWithoutImageInstability,
|
||||||
userPagesUntilAssistantImageRenders,
|
userPagesUntilAssistantImageRenders,
|
||||||
} from "../support/helpers/assistant-images";
|
} from "../support/helpers/assistant-images";
|
||||||
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
|
||||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
|
||||||
@@ -85,15 +84,8 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
|||||||
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
.poll(async () => Math.round((await transformedContent.boundingBox())?.width ?? 0))
|
||||||
.toBe(Math.round(initialBox!.width));
|
.toBe(Math.round(initialBox!.width));
|
||||||
|
|
||||||
await workspace.client.sendAgentMessage(
|
await canvas.click({ position: { x: 4, y: 4 } });
|
||||||
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 expect(lightboxImage).toHaveCount(0);
|
||||||
await expectAgentReadyToInterrupt(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
test("reloading a timeline anchors near-tail assistant image growth", async ({
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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 { execFileSync } from "node:child_process";
|
||||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Locator, type Page } from "@playwright/test";
|
import { type Locator, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||||
@@ -37,10 +37,6 @@ function changesTree(page: Page) {
|
|||||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
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> {
|
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return await readFile(filePath, "utf8");
|
return await readFile(filePath, "utf8");
|
||||||
@@ -265,12 +261,12 @@ test.afterEach(async () => {
|
|||||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithCommittedDiff();
|
const workspace = await createWorkspaceWithCommittedDiff();
|
||||||
|
|
||||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
await openWorkspaceChangesSurface(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
const tree = page.getByTestId("changes-tree-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(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||||
@@ -306,33 +302,17 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
|||||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-committed").click();
|
await page.getByTestId("changes-diff-mode-committed").click();
|
||||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await mode.click();
|
await mode.click();
|
||||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||||
|
|
||||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
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" });
|
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||||
await expect(seeCommitted).toBeVisible();
|
await expect(seeCommitted).toBeVisible();
|
||||||
await seeCommitted.click();
|
await seeCommitted.click();
|
||||||
await expect(mode).toContainText("Committed");
|
await expect(mode).toContainText("Committed");
|
||||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
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 ({
|
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||||
@@ -342,8 +322,9 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const deletedFileName = page.getByTestId("diff-file-1");
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||||
|
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||||
await deletedFileName.dblclick();
|
await deletedFileName.dblclick();
|
||||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||||
@@ -397,109 +378,49 @@ test("every interactive file header has the same hover feedback", async ({ page
|
|||||||
|
|
||||||
const first = page.getByTestId("diff-file-0-toggle");
|
const first = page.getByTestId("diff-file-0-toggle");
|
||||||
const second = page.getByTestId("diff-file-1-toggle");
|
const second = page.getByTestId("diff-file-1-toggle");
|
||||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
const normalBackground = await first.evaluate(
|
||||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
(element) => getComputedStyle(element.parentElement!).backgroundColor,
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopWidth),
|
||||||
|
)
|
||||||
|
.toBe("0px");
|
||||||
|
|
||||||
await first.hover();
|
await first.hover();
|
||||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
const hoverBackground = await first.evaluate(
|
||||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
(element) => getComputedStyle(element).backgroundColor,
|
||||||
|
);
|
||||||
|
expect(hoverBackground).not.toBe(normalBackground);
|
||||||
|
|
||||||
await first.click();
|
await first.click();
|
||||||
await page.mouse.move(0, 0);
|
await page.mouse.move(0, 0);
|
||||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
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 first.hover();
|
await first.hover();
|
||||||
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
|
await expect
|
||||||
.poll(async () => {
|
.poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
const bounds = await second.boundingBox();
|
.toBe(hoverBackground);
|
||||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
await second.hover();
|
||||||
})
|
await expect
|
||||||
.toBeCloseTo(0, 0);
|
.poll(() =>
|
||||||
|
first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor),
|
||||||
|
)
|
||||||
|
.toBe(normalBackground);
|
||||||
|
await expect
|
||||||
|
.poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||||
|
.toBe(hoverBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||||
@@ -554,8 +475,7 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
|||||||
|
|
||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const diffNames = page.locator('[data-testid^="diff-file-"][data-testid$="-name"]');
|
||||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
|
||||||
|
|
||||||
// Directories sort before files at every level, so the root note lands last
|
// 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.
|
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||||
@@ -565,17 +485,9 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
|||||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
// 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.
|
// of the diff rather than a match, and mask a real ordering difference.
|
||||||
await expect(treeNames).toHaveCount(expected.length);
|
await expect(treeNames).toHaveCount(expected.length);
|
||||||
await expect(diffHeaders).toHaveCount(expected.length);
|
await expect(diffNames).toHaveCount(expected.length);
|
||||||
await expect(treeNames).toHaveText(expected);
|
await expect(treeNames).toHaveText(expected);
|
||||||
const expectedHeaderNames = [
|
await expect(diffNames).toHaveText(expected);
|
||||||
"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 }) => {
|
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||||
@@ -674,10 +586,9 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const renamedToggle = page
|
||||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
.filter({ hasText: "zz-renamed.ts" });
|
||||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
|
||||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||||
expect(toggleTestId).not.toBeNull();
|
expect(toggleTestId).not.toBeNull();
|
||||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
@@ -704,10 +615,9 @@ test("discarding an untracked file removes it from the working tree", async ({ p
|
|||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const untrackedToggle = page
|
||||||
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
|
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||||
await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0");
|
.filter({ hasText: "zz-untracked.txt" });
|
||||||
const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
|
||||||
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||||
expect(toggleTestId).not.toBeNull();
|
expect(toggleTestId).not.toBeNull();
|
||||||
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
@@ -766,12 +676,8 @@ 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-diff-status-trigger")).toHaveCount(0);
|
||||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
"src/use-mounted-tab-set.ts, +37, -1",
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
);
|
|
||||||
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(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||||
@@ -806,18 +712,16 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
|||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
await expect(
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
timeout: 30_000,
|
||||||
).toHaveCount(0, { timeout: 30_000 });
|
});
|
||||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||||
"src/use-mounted-tab-set.ts, +37, -1",
|
timeout: 30_000,
|
||||||
{ timeout: 30_000 },
|
});
|
||||||
);
|
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||||
"src/zz-deleted.ts, +0, -1",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||||
@@ -1076,29 +980,6 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
|||||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
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 ({
|
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1482,46 +1363,6 @@ async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
|||||||
return { id: created.workspace.id, repoPath: repo.path };
|
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> {
|
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||||
files: [{ path: "src/selection.ts", content: "" }],
|
files: [{ path: "src/selection.ts", content: "" }],
|
||||||
@@ -1539,58 +1380,6 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
|||||||
return { id: created.workspace.id, repoPath: repo.path };
|
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> {
|
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
@@ -1600,15 +1389,11 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
|||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceChangesSurface(
|
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
page: Page,
|
|
||||||
workspace: DirtyWorkspace,
|
|
||||||
timeout?: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 900 });
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page, timeout);
|
await openChangesPanel(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
@@ -1617,7 +1402,7 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
|||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, {
|
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expectExpandedMountedTabDiff(page);
|
await expectExpandedMountedTabDiff(page);
|
||||||
@@ -1639,54 +1424,6 @@ 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> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -1964,30 +1701,6 @@ async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
|||||||
await page.mouse.up();
|
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<{
|
async function readDiffTypographyGeometry(page: Page): Promise<{
|
||||||
horizontalExtent: number;
|
horizontalExtent: number;
|
||||||
canvasPixels: string;
|
canvasPixels: string;
|
||||||
|
|||||||
@@ -103,15 +103,23 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||||
const [header, canvas] = await Promise.all([
|
const [header, content, name, stat] = await Promise.all([
|
||||||
panel.getByTestId("diff-file-0").boundingBox(),
|
panel.getByTestId("diff-file-0").boundingBox(),
|
||||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
panel.getByTestId("diff-file-0-header-content").boundingBox(),
|
||||||
|
panel.getByTestId("diff-file-0-name").boundingBox(),
|
||||||
|
panel.getByTestId("diff-file-0-stat").boundingBox(),
|
||||||
]);
|
]);
|
||||||
expect(header).not.toBeNull();
|
expect(header).not.toBeNull();
|
||||||
expect(canvas).not.toBeNull();
|
expect(content).not.toBeNull();
|
||||||
|
expect(name).not.toBeNull();
|
||||||
|
expect(stat).not.toBeNull();
|
||||||
expect(header!.height).toBeCloseTo(30, 0);
|
expect(header!.height).toBeCloseTo(30, 0);
|
||||||
expect(header!.x).toBeCloseTo(canvas!.x, 0);
|
expect(content!.x).toBeCloseTo(header!.x, 0);
|
||||||
expect(header!.width).toBeCloseTo(canvas!.width, 0);
|
expect(content!.width).toBeCloseTo(header!.width, 0);
|
||||||
expect(header!.y).toBeCloseTo(canvas!.y, 0);
|
expect(name!.x - header!.x).toBeCloseTo(12, 0);
|
||||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("feature.txt, +2, -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helper
|
|||||||
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
|
||||||
|
|
||||||
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
|
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 diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
|
||||||
const LINE_COUNT_PER_FILE = 1_200;
|
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 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 CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
|
||||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||||
@@ -23,10 +21,8 @@ const POC_MEDIAN_FRAME_MS = Number(
|
|||||||
const POC_P95_FRAME_MS = Number(
|
const POC_P95_FRAME_MS = Number(
|
||||||
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
|
||||||
);
|
);
|
||||||
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 2.6;
|
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
|
||||||
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 2.4;
|
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
|
||||||
const MANY_FILE_MAX_MEDIAN_FRAME_MS = 45;
|
|
||||||
const MANY_FILE_MAX_P95_FRAME_MS = 80;
|
|
||||||
|
|
||||||
diffPerfDescribe("Diff canvas performance", () => {
|
diffPerfDescribe("Diff canvas performance", () => {
|
||||||
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
test("opening a review stays responsive in a large diff", async ({ page }) => {
|
||||||
@@ -62,131 +58,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("many small files keep canvas headers bounded and responsive", async ({
|
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
|
||||||
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);
|
test.setTimeout(180_000);
|
||||||
const repo = await createLargeDiffRepository();
|
const repo = await createLargeDiffRepository();
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
@@ -208,7 +80,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await expectBoundedCanvasSurfaces(page);
|
await expectSingleCanvasSurface(page);
|
||||||
cdp = await page.context().newCDPSession(page);
|
cdp = await page.context().newCDPSession(page);
|
||||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
|
||||||
await startChromiumTrace(cdp);
|
await startChromiumTrace(cdp);
|
||||||
@@ -238,9 +110,6 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
|
||||||
const renderer = await readRendererInstrumentation(page);
|
const renderer = await readRendererInstrumentation(page);
|
||||||
const measuredCycles = scrollReports.slice(1);
|
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(
|
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
|
||||||
MAX_MEDIAN_FRAME_MS,
|
MAX_MEDIAN_FRAME_MS,
|
||||||
);
|
);
|
||||||
@@ -253,9 +122,9 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
|
||||||
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
|
||||||
);
|
);
|
||||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
expect(renderer.addedElements).toBe(0);
|
||||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
expect(renderer.removedElements).toBe(0);
|
||||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||||
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
|
||||||
@@ -300,7 +169,7 @@ async function measureAdditionalPerformanceScenario(
|
|||||||
testInfo: TestInfo,
|
testInfo: TestInfo,
|
||||||
name: "wrapped" | "split",
|
name: "wrapped" | "split",
|
||||||
) {
|
) {
|
||||||
await expectBoundedCanvasSurfaces(page);
|
await expectSingleCanvasSurface(page);
|
||||||
await startRendererInstrumentation(page);
|
await startRendererInstrumentation(page);
|
||||||
const scrollReports = [];
|
const scrollReports = [];
|
||||||
const heapUsage = [];
|
const heapUsage = [];
|
||||||
@@ -333,9 +202,9 @@ async function measureAdditionalPerformanceScenario(
|
|||||||
}
|
}
|
||||||
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
|
||||||
const renderer = await readRendererInstrumentation(page);
|
const renderer = await readRendererInstrumentation(page);
|
||||||
expect(renderer.addedElements).toBeLessThanOrEqual(40);
|
expect(renderer.addedElements).toBe(0);
|
||||||
expect(renderer.removedElements).toBeLessThanOrEqual(40);
|
expect(renderer.removedElements).toBe(0);
|
||||||
expect(renderer.reactCommits).toBeLessThanOrEqual(30);
|
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
|
||||||
return {
|
return {
|
||||||
scrollReports,
|
scrollReports,
|
||||||
heapUsage,
|
heapUsage,
|
||||||
@@ -382,20 +251,6 @@ async function createLargeDiffRepository() {
|
|||||||
return repo;
|
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> {
|
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
@@ -434,8 +289,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
|||||||
return performance.now() - startedAt;
|
return performance.now() - startedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
async function expectSingleCanvasSurface(page: Page): Promise<void> {
|
||||||
await expect(page.locator("canvas")).toHaveCount(2);
|
await expect(page.locator("canvas")).toHaveCount(1);
|
||||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||||
const elementCount = await page
|
const elementCount = await page
|
||||||
.getByTestId("git-diff-canvas-root")
|
.getByTestId("git-diff-canvas-root")
|
||||||
@@ -522,92 +377,6 @@ 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 {
|
interface CapturedCanvasFrame {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
image: Buffer;
|
image: Buffer;
|
||||||
@@ -685,33 +454,21 @@ async function expectContinuousFramesMatchStableRender(
|
|||||||
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const scroller = page.getByTestId("git-diff-scroll");
|
|
||||||
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
const first = page.getByTestId("diff-file-0-horizontal-scroll");
|
||||||
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
const second = page.getByTestId("diff-file-1-horizontal-scroll");
|
||||||
await setVerticalOffset(scroller, 0);
|
const firstOffset = await setHorizontalOffset(first, 240);
|
||||||
await expect(first).toBeAttached();
|
const secondOffset = await setHorizontalOffset(second, 120);
|
||||||
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);
|
expect(firstOffset).not.toBe(secondOffset);
|
||||||
await setVerticalOffset(scroller, 0);
|
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||||
await expect(first).toBeAttached();
|
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||||
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 }));
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
await new Promise<void>((resolve) =>
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
element.scrollTop = 0;
|
||||||
);
|
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||||
}, offset);
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
|
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
|
||||||
|
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setHorizontalOffset(locator: Locator, offset: number) {
|
async function setHorizontalOffset(locator: Locator, offset: number) {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
let diagramTurnCompleted!: Promise<void>;
|
|
||||||
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
const agent = await seedMockAgentWorkspace({
|
||||||
repoPrefix: "mermaid-streaming-",
|
repoPrefix: "mermaid-streaming-",
|
||||||
@@ -49,16 +48,15 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
|
|||||||
await test.step("Open the conversation and request a diagram", async () => {
|
await test.step("Open the conversation and request a diagram", async () => {
|
||||||
await openAgentRoute(page, agent);
|
await openAgentRoute(page, agent);
|
||||||
await requestDiagram(agent);
|
await requestDiagram(agent);
|
||||||
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
|
||||||
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
await expectDiagramWithLabels(page, ["Start", "Middle"]);
|
||||||
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
|
await expectDiagramRemainsRenderedWhileStreaming(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("The completed diagram shows the final streamed content", async () => {
|
await test.step("The completed diagram shows the final streamed content", async () => {
|
||||||
await diagramTurnCompleted;
|
await waitForDiagramTurnToComplete(agent);
|
||||||
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ test("chooses a metadata model and can return to automatic selection", async ({
|
|||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await openMetadataGenerationSettings(page);
|
await openMetadataGenerationSettings(page);
|
||||||
|
|
||||||
// The section explains itself through the header's info tooltip, not a paragraph.
|
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
|
||||||
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
|
|
||||||
"About Metadata generation",
|
|
||||||
);
|
|
||||||
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { buildAgentRoute } from "../support/helpers/mock-agent";
|
|||||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
|
import {
|
||||||
|
closeMobileAgentSidebar,
|
||||||
|
expectMobileAgentSidebarHidden,
|
||||||
|
} from "../support/helpers/sidebar";
|
||||||
import {
|
import {
|
||||||
switchWorkspaceViaSidebar,
|
switchWorkspaceViaSidebar,
|
||||||
waitForWorkspaceInSidebar,
|
waitForWorkspaceInSidebar,
|
||||||
@@ -156,7 +159,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
|
|||||||
|
|
||||||
async function openCompactSidebar(page: Page): Promise<void> {
|
async function openCompactSidebar(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||||
await expect(page.getByTestId("sidebar-search")).toBeVisible();
|
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||||
@@ -328,9 +331,8 @@ test.describe("plugin workspace panels and Command Center", () => {
|
|||||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||||
await openCompactSidebar(page);
|
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 runCommand(page, "Open plugin agent");
|
||||||
|
await closeMobileAgentSidebar(page);
|
||||||
await expectMobileAgentSidebarHidden(page);
|
await expectMobileAgentSidebarHidden(page);
|
||||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
openPrPane,
|
openPrPane,
|
||||||
expectPrPaneTitle,
|
expectPrPaneTitle,
|
||||||
expectPrPaneState,
|
expectPrPaneState,
|
||||||
expectPrPaneChecks,
|
expectPrPaneCheckSummary,
|
||||||
expectPrPaneActivity,
|
expectPrPaneActivityCount,
|
||||||
} from "../support/helpers/pr-pane";
|
} from "../support/helpers/pr-pane";
|
||||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,7 @@ test.describe("PR pane", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||||
|
{ title: "PR with no checks", state: "open" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,21 +102,25 @@ test.describe("PR pane", () => {
|
|||||||
await expectPrPaneTitle(page, "Work in progress");
|
await expectPrPaneTitle(page, "Work in progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders seeded checks in their success, failure, and pending groups", async ({ page }) => {
|
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneChecks(page, {
|
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||||
success: ["build-1", "build-2"],
|
|
||||||
failure: ["deploy"],
|
|
||||||
pending: ["security"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders every seeded activity comment", async ({ page }) => {
|
test("renders activity rows with correct count", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneActivity(page, ["Test comment 1", "Test comment 2", "Test comment 3"]);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const cases: ProviderSubagentCase[] = [
|
|||||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||||
expectedName: "Verify OpenCode descriptor",
|
expectedName: "Verify OpenCode descriptor",
|
||||||
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i,
|
||||||
|
expectsUserMessage: false,
|
||||||
providerConfig: { model: "openai/gpt-5.4" },
|
providerConfig: { model: "openai/gpt-5.4" },
|
||||||
prompt:
|
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.',
|
'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 expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
// Workspace B (same directory): the SAME directory-backed content is
|
// Workspace B (same directory): the SAME directory-backed content is
|
||||||
// visible even though the panel tabs belong to a different workspace.
|
// 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 expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
|
||||||
await openChangesPanel(page);
|
await openChangesPanel(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
|
||||||
).toHaveAccessibleName("SHARED_CHANGE.md, +1, -0", { timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await seeded.cleanup();
|
await seeded.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
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,6 +13,7 @@ import {
|
|||||||
expectTurnCopyButton,
|
expectTurnCopyButton,
|
||||||
} from "../support/helpers/agent-stream";
|
} from "../support/helpers/agent-stream";
|
||||||
import {
|
import {
|
||||||
|
expectReconnectingToastDebounced,
|
||||||
expectReconnectingToastGone,
|
expectReconnectingToastGone,
|
||||||
expectReconnectingToastVisible,
|
expectReconnectingToastVisible,
|
||||||
} from "../support/helpers/workspace-ui";
|
} from "../support/helpers/workspace-ui";
|
||||||
@@ -180,17 +181,13 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
await gate.drop();
|
await gate.drop();
|
||||||
await gate.waitForBlockedConnection();
|
await expectReconnectingToastDebounced(page);
|
||||||
|
await expectReconnectingToastVisible(page);
|
||||||
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
||||||
).toHaveCount(0);
|
).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();
|
gate.restore();
|
||||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
|
||||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
|
||||||
await expectReconnectingToastGone(page);
|
await expectReconnectingToastGone(page);
|
||||||
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
||||||
exact: true,
|
exact: true,
|
||||||
@@ -212,7 +209,6 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||||
await selectAgent(page, "First viewed chat");
|
await selectAgent(page, "First viewed chat");
|
||||||
await gate.drop();
|
await gate.drop();
|
||||||
await gate.waitForBlockedConnection();
|
|
||||||
await expectReconnectingToastVisible(page);
|
await expectReconnectingToastVisible(page);
|
||||||
|
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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 { expect, test, type Page } from "../support/fixtures";
|
||||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||||
import {
|
import {
|
||||||
createMockIdleAgent,
|
createIdleAgent,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
expectWorkspaceTabVisible,
|
expectWorkspaceTabVisible,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
@@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await createMockIdleAgent(workspace.client, {
|
const agent = await createIdleAgent(workspace.client, {
|
||||||
cwd: workspace.repoPath,
|
cwd: workspace.repoPath,
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
title: `workspace-reconnect-${Date.now()}`,
|
title: `workspace-reconnect-${Date.now()}`,
|
||||||
@@ -180,7 +180,6 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
await daemonGate.waitForBlockedConnection();
|
|
||||||
await expectReconnectingToastVisible(page);
|
await expectReconnectingToastVisible(page);
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
@@ -218,12 +217,12 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createMockIdleAgent(primaryWorkspace.client, {
|
createIdleAgent(primaryWorkspace.client, {
|
||||||
cwd: primaryWorkspace.repoPath,
|
cwd: primaryWorkspace.repoPath,
|
||||||
workspaceId: primaryWorkspace.workspaceId,
|
workspaceId: primaryWorkspace.workspaceId,
|
||||||
title: "Active host agent",
|
title: "Active host agent",
|
||||||
}),
|
}),
|
||||||
createMockIdleAgent(secondaryWorkspace.client, {
|
createIdleAgent(secondaryWorkspace.client, {
|
||||||
cwd: secondaryWorkspace.repoPath,
|
cwd: secondaryWorkspace.repoPath,
|
||||||
workspaceId: secondaryWorkspace.workspaceId,
|
workspaceId: secondaryWorkspace.workspaceId,
|
||||||
title: "Inactive host agent",
|
title: "Inactive host agent",
|
||||||
@@ -320,12 +319,12 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
|
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||||
cwd: firstWorkspace.repoPath,
|
cwd: firstWorkspace.repoPath,
|
||||||
workspaceId: firstWorkspace.workspaceId,
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
title: `workspace-nav-a-${Date.now()}`,
|
title: `workspace-nav-a-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
|
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||||
cwd: secondWorkspace.repoPath,
|
cwd: secondWorkspace.repoPath,
|
||||||
workspaceId: secondWorkspace.workspaceId,
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
title: `workspace-nav-b-${Date.now()}`,
|
title: `workspace-nav-b-${Date.now()}`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type Locator, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
|
||||||
@@ -14,31 +14,6 @@ function getVisibleChatScroll(page: Page) {
|
|||||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
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> {
|
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||||
@@ -205,53 +180,58 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
|||||||
const visibleButtonBounds = buttonBounds!;
|
const visibleButtonBounds = buttonBounds!;
|
||||||
|
|
||||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
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 buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||||
const initialToolCalls = await readRenderedToolCalls(toolCalls);
|
const candidate = toolCallBounds
|
||||||
const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY);
|
.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];
|
||||||
expect(
|
expect(
|
||||||
initialCandidate,
|
candidate,
|
||||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||||
buttonBounds,
|
buttonBounds,
|
||||||
scrollMetrics: await readScrollMetrics(page),
|
scrollMetrics: await readScrollMetrics(page),
|
||||||
toolCallBounds: initialToolCalls.map(({ bounds }) => bounds),
|
toolCallBounds,
|
||||||
})}`,
|
})}`,
|
||||||
).toBeDefined();
|
).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
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const currentButtonBounds = await scrollToBottomButton.boundingBox();
|
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||||
if (!currentButtonBounds) return false;
|
scrollToBottomButton.boundingBox(),
|
||||||
const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2;
|
alignedToolCall.boundingBox(),
|
||||||
const closest = closestToolCallToY(
|
]);
|
||||||
await readRenderedToolCalls(toolCalls),
|
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||||
currentButtonCenterY,
|
return false;
|
||||||
);
|
}
|
||||||
if (!closest) return false;
|
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||||
const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2;
|
return (
|
||||||
if (
|
|
||||||
toolCallCenterY >= currentButtonBounds.y &&
|
toolCallCenterY >= currentButtonBounds.y &&
|
||||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
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);
|
.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([
|
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||||
scrollToBottomButton.boundingBox(),
|
scrollToBottomButton.boundingBox(),
|
||||||
alignedToolCall!.boundingBox(),
|
alignedToolCall.boundingBox(),
|
||||||
]);
|
]);
|
||||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
@@ -265,7 +245,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi
|
|||||||
x: finalToolCallBounds.x + 24,
|
x: finalToolCallBounds.x + 24,
|
||||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
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);
|
const hit = document.elementFromPoint(point.x, point.y);
|
||||||
return hit !== null && toolCall.contains(hit);
|
return hit !== null && toolCall.contains(hit);
|
||||||
}, clickPoint);
|
}, clickPoint);
|
||||||
|
|||||||
@@ -52,18 +52,18 @@ export interface IdleAgentSeedClient {
|
|||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedIdleAgent(
|
export async function createIdleAgent(
|
||||||
client: IdleAgentSeedClient,
|
client: IdleAgentSeedClient,
|
||||||
input: { cwd: string; workspaceId: string; title: string },
|
input: { cwd: string; workspaceId: string; title: string },
|
||||||
provider: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
modeId: string;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
): Promise<ArchiveTabAgent> {
|
): Promise<ArchiveTabAgent> {
|
||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
...provider,
|
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 },
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -84,32 +84,6 @@ async function seedIdleAgent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export async function archiveAgentFromDaemon(
|
||||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
|
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
|
||||||
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
|
||||||
await page.keyboard.press("Meta+K");
|
await page.keyboard.press("Meta+K");
|
||||||
const panel = page.getByTestId("command-center-panel");
|
const panel = page.getByTestId("command-center-panel");
|
||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -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.
|
// Opens the command center / global search palette from the sidebar and returns its panel.
|
||||||
export async function openCommandCenter(page: Page): Promise<Locator> {
|
export async function openCommandCenter(page: Page): Promise<Locator> {
|
||||||
await page.getByTestId("sidebar-search").click();
|
await page.getByTestId("sidebar-command-center-search").click();
|
||||||
const panel = page.getByTestId("command-center-panel");
|
const panel = page.getByTestId("command-center-panel");
|
||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
return panel;
|
return panel;
|
||||||
|
|||||||
@@ -315,9 +315,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
const suppressedAgentStreamEventTypes = new Set<string>();
|
const suppressedAgentStreamEventTypes = new Set<string>();
|
||||||
const suppressedAgentStreamItemTypes = new Set<string>();
|
const suppressedAgentStreamItemTypes = new Set<string>();
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
let blockedConnectionCount = 0;
|
|
||||||
let blockedConnectionCountAtDrop = 0;
|
|
||||||
const blockedConnectionWaiters = new Set<() => void>();
|
|
||||||
let latestServer: WebSocketRoute | null = null;
|
let latestServer: WebSocketRoute | null = null;
|
||||||
const directoryStarts: DirectoryRequestStartCounts = {
|
const directoryStarts: DirectoryRequestStartCounts = {
|
||||||
subscribed: { agents: 0, workspaces: 0 },
|
subscribed: { agents: 0, workspaces: 0 },
|
||||||
@@ -446,9 +443,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
blockedConnectionCount += 1;
|
|
||||||
for (const resolve of blockedConnectionWaiters) resolve();
|
|
||||||
blockedConnectionWaiters.clear();
|
|
||||||
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -589,7 +583,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
forward?.();
|
forward?.();
|
||||||
},
|
},
|
||||||
async drop(): Promise<void> {
|
async drop(): Promise<void> {
|
||||||
blockedConnectionCountAtDrop = blockedConnectionCount;
|
|
||||||
acceptingConnections = false;
|
acceptingConnections = false;
|
||||||
const sockets = Array.from(activeSockets);
|
const sockets = Array.from(activeSockets);
|
||||||
activeSockets.clear();
|
activeSockets.clear();
|
||||||
@@ -599,10 +592,6 @@ export async function installDaemonWebSocketGate(page: Page) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
async waitForBlockedConnection(): Promise<void> {
|
|
||||||
if (blockedConnectionCount > blockedConnectionCountAtDrop) return;
|
|
||||||
await new Promise<void>((resolve) => blockedConnectionWaiters.add(resolve));
|
|
||||||
},
|
|
||||||
restore(): void {
|
restore(): void {
|
||||||
acceptingConnections = true;
|
acceptingConnections = true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,16 +25,13 @@ export async function expectDiagramWithLabels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectDiagramRemainsRenderedWhileStreaming(
|
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
|
||||||
page: Page,
|
const { diagram, svg } = renderedDiagram(page);
|
||||||
completion: Promise<void>,
|
const samples = 80;
|
||||||
): Promise<void> {
|
for (let sample = 0; sample < samples; sample += 1) {
|
||||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
await expect(diagram).toBeVisible({ timeout: 100 });
|
||||||
const completed = completion.then(() => true);
|
await expect(svg).toBeVisible({ timeout: 500 });
|
||||||
for (;;) {
|
await page.waitForTimeout(25);
|
||||||
expect(await diagram.isVisible()).toBe(true);
|
|
||||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
|
||||||
if (didComplete) return completion;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCommandCenter(page: Page): Promise<void> {
|
async function openCommandCenter(page: Page): Promise<void> {
|
||||||
await page.getByTestId("sidebar-search").click();
|
await page.getByRole("button", { name: "Open command center" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DirectoryBootstrapScenario {
|
export class DirectoryBootstrapScenario {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
||||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||||
import { openPullRequestPanel } from "./workspace-tabs";
|
import { openPullRequestPanel } from "./workspace-tabs";
|
||||||
|
|
||||||
@@ -19,31 +20,23 @@ export async function expectPrPaneState(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectPrPaneChecks(
|
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 expectPrPaneCheckSummary(
|
||||||
page: Page,
|
page: Page,
|
||||||
checks: { success: string[]; failure: string[]; pending: string[] },
|
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const section = page.getByTestId("pr-pane-checks");
|
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
||||||
const groups = [
|
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
||||||
{ status: "failure", label: /failing checks?$/, names: checks.failure },
|
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||||
{ 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 expectPrPaneActivity(page: Page, bodies: string[]): Promise<void> {
|
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||||
const activity = page.getByTestId("pr-pane-activity-row");
|
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||||
for (const body of bodies) {
|
|
||||||
await expect(activity.getByText(body, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
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)) })),
|
|
||||||
);
|
|
||||||
const laidOut = measured.filter(
|
|
||||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
|
||||||
);
|
|
||||||
if (laidOut.length !== keys.length) return null;
|
|
||||||
return laidOut.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
|
||||||
},
|
|
||||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toEqual(keys);
|
|
||||||
}
|
|
||||||
@@ -102,16 +102,16 @@ export async function openChangesTreePanel(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openChangesPanel(page: Page, timeout = 30_000): Promise<void> {
|
export async function openChangesPanel(page: Page): Promise<void> {
|
||||||
await openChangesTreePanel(page);
|
await openChangesTreePanel(page);
|
||||||
const changedFile = page
|
const changedFile = page
|
||||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||||
.filter({ visible: true })
|
.filter({ visible: true })
|
||||||
.first();
|
.first();
|
||||||
await expect(changedFile).toBeVisible({ timeout });
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
await changedFile.click();
|
await changedFile.click();
|
||||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||||
timeout,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ 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(
|
export async function expectHostConnectingOrOffline(
|
||||||
page: Page,
|
page: Page,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.7.2",
|
"version": "0.7.0-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run start:expo",
|
"start": "npm run start:expo",
|
||||||
"start:expo": "cross-env APP_VARIANT=development expo start",
|
"start:expo": "cross-env APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:embedded-webviews",
|
||||||
"android": "npm run android:development",
|
"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: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",
|
"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,7 +35,10 @@
|
|||||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.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",
|
"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: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"
|
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -162,10 +165,5 @@
|
|||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
"wrangler": "^4.105.0",
|
"wrangler": "^4.105.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
|
||||||
"reanimated": {
|
|
||||||
"staticFeatureFlags": {
|
|
||||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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,6 +65,7 @@ function timingFor(assistantId: string): Map<string, TurnTiming> {
|
|||||||
[
|
[
|
||||||
assistantId,
|
assistantId,
|
||||||
{
|
{
|
||||||
|
startedAt: new Date("2026-08-22T10:00:00.000Z"),
|
||||||
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
completedAt: new Date("2026-08-22T10:00:02.000Z"),
|
||||||
durationMs: 2_000,
|
durationMs: 2_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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,6 +73,7 @@ function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought
|
|||||||
|
|
||||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||||
const timing = {
|
const timing = {
|
||||||
|
startedAt: timestamp(1),
|
||||||
completedAt: timestamp(9),
|
completedAt: timestamp(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
};
|
};
|
||||||
@@ -365,23 +366,6 @@ 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", () => {
|
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||||
const shell = toolCall("tool-1", 2);
|
const shell = toolCall("tool-1", 2);
|
||||||
const thinking = thought("thought-1", 3);
|
const thinking = thought("thought-1", 3);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface TurnFooterHost {
|
|||||||
|
|
||||||
export interface StreamLayoutItem {
|
export interface StreamLayoutItem {
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
|
index: number;
|
||||||
|
items: StreamItem[];
|
||||||
aboveItem: StreamItem | null;
|
aboveItem: StreamItem | null;
|
||||||
belowItem: StreamItem | null;
|
belowItem: StreamItem | null;
|
||||||
gapBelow: number;
|
gapBelow: number;
|
||||||
@@ -230,109 +232,60 @@ function getSegmentNeighbor(input: {
|
|||||||
return null;
|
return 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>();
|
|
||||||
|
|
||||||
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[] {
|
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||||
return input.items.map((item, index) => layoutSegmentItem(input, item, index));
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
function layoutSegmentItem(
|
return {
|
||||||
input: LayoutSegmentInput,
|
item,
|
||||||
item: StreamItem,
|
index,
|
||||||
index: number,
|
items: input.items,
|
||||||
): StreamLayoutItem {
|
aboveItem,
|
||||||
const aboveItem = getSegmentNeighbor({
|
belowItem,
|
||||||
strategy: input.strategy,
|
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
|
||||||
items: input.items,
|
assistantSpacing,
|
||||||
index,
|
completedFooter,
|
||||||
relation: "above",
|
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
|
||||||
boundaryIndex: input.boundaryIndex,
|
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
|
||||||
boundaryItem: input.boundaryAboveItem,
|
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
|
||||||
});
|
isLastInToolSequence:
|
||||||
const belowItem = getSegmentNeighbor({
|
isToolSequenceItem(item) &&
|
||||||
strategy: input.strategy,
|
!(isToolSequenceItem(belowItem) && continuesTurn(item, belowItem)),
|
||||||
items: input.items,
|
frameOrder: input.frameOrder,
|
||||||
index,
|
phase: input.phase,
|
||||||
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,7 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
|
||||||
import { findMountedWindowStart } from "./history-window";
|
|
||||||
import { buildAgentStreamRenderModel } from "./model";
|
import { buildAgentStreamRenderModel } from "./model";
|
||||||
|
|
||||||
function createTimestamp(seed: number): Date {
|
function createTimestamp(seed: number): Date {
|
||||||
@@ -71,36 +69,12 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||||
|
startedAt: tail[2]?.timestamp,
|
||||||
completedAt: tail[3]?.timestamp,
|
completedAt: tail[3]?.timestamp,
|
||||||
durationMs: 1000,
|
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", () => {
|
it("keeps head separate from committed history on desktop web", () => {
|
||||||
const tail: StreamItem[] = [];
|
const tail: StreamItem[] = [];
|
||||||
for (let index = 0; index < 60; index += 1) {
|
for (let index = 0; index < 60; index += 1) {
|
||||||
@@ -202,6 +176,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||||
|
startedAt: tail[0]?.timestamp,
|
||||||
completedAt: head[0]?.timestamp,
|
completedAt: head[0]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -221,6 +196,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
|
|
||||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||||
|
startedAt: tail[0]?.timestamp,
|
||||||
completedAt: tail[1]?.timestamp,
|
completedAt: tail[1]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
|
||||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
@@ -64,6 +63,24 @@ const historyStartSlotStyle: ViewStyle = {
|
|||||||
};
|
};
|
||||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
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 {
|
function keyExtractor(item: { id: string }): string {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
@@ -121,7 +138,27 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
return [...segments.historyVirtualized, ...segments.historyMounted];
|
return [...segments.historyVirtualized, ...segments.historyMounted];
|
||||||
}, [segments.historyMounted, segments.historyVirtualized]);
|
}, [segments.historyMounted, segments.historyVirtualized]);
|
||||||
const historyRows = useRevisedHistoryRows(historyItems, historyRowRevision);
|
// 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 getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||||
const metrics = streamViewportMetricsRef.current;
|
const metrics = streamViewportMetricsRef.current;
|
||||||
const hasMeasuredViewport =
|
const hasMeasuredViewport =
|
||||||
|
|||||||
@@ -252,74 +252,6 @@ describe("createWebStreamStrategy", () => {
|
|||||||
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
|
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", () => {
|
it("keeps a row mounted when it moves from the live head into mounted history", () => {
|
||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
shouldAdjustScrollForVirtualRowResize,
|
shouldAdjustScrollForVirtualRowResize,
|
||||||
} from "./web-virtualization";
|
} from "./web-virtualization";
|
||||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
|
||||||
import { createStreamStrategy } from "./strategy";
|
import { createStreamStrategy } from "./strategy";
|
||||||
import {
|
import {
|
||||||
abandonHistoryStartPaginationRequest,
|
abandonHistoryStartPaginationRequest,
|
||||||
@@ -280,8 +279,7 @@ function isScrollContainerOverscrolledPastBottom(
|
|||||||
|
|
||||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||||
const {
|
const {
|
||||||
segments: inputSegments,
|
segments,
|
||||||
historyRowRevision,
|
|
||||||
liveHeadRowRevision,
|
liveHeadRowRevision,
|
||||||
boundary,
|
boundary,
|
||||||
renderers,
|
renderers,
|
||||||
@@ -298,15 +296,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollEnabled,
|
scrollEnabled,
|
||||||
isMobileBreakpoint,
|
isMobileBreakpoint,
|
||||||
} = props;
|
} = 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 isActive = useRetainedPanelActive();
|
||||||
const isActiveRef = useRef(isActive);
|
const isActiveRef = useRef(isActive);
|
||||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|||||||
@@ -86,13 +86,7 @@ describe("canonical turn membership", () => {
|
|||||||
assistant("heartbeat-2-reply", 6, "turn-3"),
|
assistant("heartbeat-2-reply", 6, "turn-3"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const completed = layoutFor(items, false);
|
expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]);
|
||||||
|
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
|
|
||||||
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
|
|
||||||
completedAt: at(6),
|
|
||||||
durationMs: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -124,6 +118,7 @@ describe("canonical turn membership", () => {
|
|||||||
const completed = layoutFor(completedItems, false);
|
const completed = layoutFor(completedItems, false);
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||||
|
startedAt: at(1),
|
||||||
completedAt: at(9),
|
completedAt: at(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ import type { Theme } from "@/styles/theme";
|
|||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||||
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
|
import { PluginTimelineItemView } from "@/plugins/timeline";
|
||||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
|
||||||
|
|
||||||
function renderLiveAuxiliaryNode(input: {
|
function renderLiveAuxiliaryNode(input: {
|
||||||
pendingPermissions: ReactNode;
|
pendingPermissions: ReactNode;
|
||||||
@@ -221,22 +220,6 @@ 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: {
|
function renderHistoryStreamItem(input: {
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
layoutItemById: Map<string, StreamLayoutItem>;
|
layoutItemById: Map<string, StreamLayoutItem>;
|
||||||
@@ -246,13 +229,7 @@ function renderHistoryStreamItem(input: {
|
|||||||
if (!layoutItem) {
|
if (!layoutItem) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return input.renderStreamItem(layoutItem);
|
||||||
<HistoryStreamRow
|
|
||||||
item={input.item}
|
|
||||||
layoutItem={layoutItem}
|
|
||||||
renderStreamItem={input.renderStreamItem}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveHeadStreamItem(input: {
|
function renderLiveHeadStreamItem(input: {
|
||||||
@@ -376,7 +353,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||||
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
|
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||||
const sessionStreamHead = useSessionStore((state) =>
|
const sessionStreamHead = useSessionStore((state) =>
|
||||||
@@ -559,13 +535,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
toolCallDetailLevel,
|
toolCallDetailLevel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const projectedPlugins = useMemo(
|
|
||||||
() => ({
|
|
||||||
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
|
|
||||||
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
|
|
||||||
}),
|
|
||||||
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
start: historyWindowStart,
|
start: historyWindowStart,
|
||||||
hasLocalHistory,
|
hasLocalHistory,
|
||||||
@@ -573,7 +542,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
loadOlder,
|
loadOlder,
|
||||||
} = useStreamHistoryWindow({
|
} = useStreamHistoryWindow({
|
||||||
agentId,
|
agentId,
|
||||||
items: projectedPlugins.tail,
|
items: projectedToolCalls.tail,
|
||||||
loadRemoteOlder,
|
loadRemoteOlder,
|
||||||
});
|
});
|
||||||
const isLoadingOlder = remoteIsLoadingOlder;
|
const isLoadingOlder = remoteIsLoadingOlder;
|
||||||
@@ -584,8 +553,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||||
tail: projectedPlugins.tail,
|
tail: projectedToolCalls.tail,
|
||||||
head: projectedPlugins.head,
|
head: projectedToolCalls.head,
|
||||||
platform: isWeb ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
historyStart: historyWindowStart,
|
historyStart: historyWindowStart,
|
||||||
@@ -593,8 +562,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}, [
|
}, [
|
||||||
isMobile,
|
isMobile,
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
projectedPlugins.head,
|
projectedToolCalls.head,
|
||||||
projectedPlugins.tail,
|
projectedToolCalls.tail,
|
||||||
effectiveTurnPresentation.startedAt,
|
effectiveTurnPresentation.startedAt,
|
||||||
historyWindowStart,
|
historyWindowStart,
|
||||||
]);
|
]);
|
||||||
@@ -820,14 +789,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
[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(
|
const renderToolCallItem = useCallback(
|
||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||||
const group = getToolCallGroup(item.id);
|
const group = projectedToolCalls.groupsByHostId.get(item.id);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||||
}
|
}
|
||||||
@@ -854,8 +818,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
projectedToolCalls.groupsByHostId,
|
||||||
expandedToolCallGroupIds,
|
expandedToolCallGroupIds,
|
||||||
getToolCallGroup,
|
|
||||||
renderSingleToolCallItem,
|
renderSingleToolCallItem,
|
||||||
setToolCallGroupExpanded,
|
setToolCallGroupExpanded,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
|
|||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
|
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||||
import { I18nProvider } from "@/i18n/provider";
|
import { I18nProvider } from "@/i18n/provider";
|
||||||
import {
|
import {
|
||||||
KeyboardActionDispatcherProvider,
|
KeyboardActionDispatcherProvider,
|
||||||
@@ -114,7 +114,7 @@ import {
|
|||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||||
import { getNextThemePreference } from "@/styles/theme";
|
import { getNextThemePreference } from "@/styles/theme";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -627,9 +627,10 @@ function SidebarChrome({
|
|||||||
keyboardShortcutsEnabled: boolean;
|
keyboardShortcutsEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
const isOpen = usePanelStore((state) =>
|
||||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
);
|
||||||
|
const active = visible && isOpen;
|
||||||
return (
|
return (
|
||||||
<SidebarModelProvider active={active}>
|
<SidebarModelProvider active={active}>
|
||||||
{mounted ? <LeftSidebar active={active} /> : null}
|
{mounted ? <LeftSidebar active={active} /> : null}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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,8 +136,6 @@ let container: HTMLElement | null = null;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
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("React", React);
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
vi.stubGlobal("window", dom.window);
|
vi.stubGlobal("window", dom.window);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { SPACING } from "@/styles/theme";
|
|||||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
|
||||||
|
|
||||||
export type ImageLightboxSource =
|
export type ImageLightboxSource =
|
||||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||||
@@ -34,27 +33,23 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||||
const [errored, setErrored] = useState(false);
|
const [errored, setErrored] = useState(false);
|
||||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(false);
|
setErrored(false);
|
||||||
}, [metadata?.id, url]);
|
}, [metadata?.id, url]);
|
||||||
|
|
||||||
const handleWebOverlayKeyDown = useCallback(
|
useEffect(() => {
|
||||||
(event: KeyboardEvent) => {
|
if (!isWeb || !source) return;
|
||||||
if (event.key !== "Escape") return false;
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
event.preventDefault();
|
if (event.key === "Escape") {
|
||||||
event.stopPropagation();
|
onClose();
|
||||||
onClose();
|
}
|
||||||
return true;
|
}
|
||||||
},
|
window.addEventListener("keydown", handleKeydown);
|
||||||
[onClose],
|
return () => {
|
||||||
);
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
const setWebOverlayScope = useWebOverlayRegistration({
|
};
|
||||||
active: isWeb && source !== null,
|
}, [onClose, source]);
|
||||||
layer: modalLayer,
|
|
||||||
onKeyDown: handleWebOverlayKeyDown,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentLayerStyle = useMemo(
|
const contentLayerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -92,7 +87,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||||
<ModalRoot style={styles.root}>
|
<ModalRoot style={styles.root}>
|
||||||
<WindowChromeRootRegion corners="both">
|
<WindowChromeRootRegion corners="both">
|
||||||
<View ref={setWebOverlayScope} style={styles.root}>
|
<View style={styles.root}>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="attachment-lightbox-backdrop"
|
testID="attachment-lightbox-backdrop"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import {
|
|||||||
NativeExplorerSidebarDock,
|
NativeExplorerSidebarDock,
|
||||||
} from "@/components/compact-explorer-sidebar";
|
} from "@/components/compact-explorer-sidebar";
|
||||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||||
@@ -52,7 +51,7 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
): CompactExplorerSidebarHostModel | null {
|
): CompactExplorerSidebarHostModel | null {
|
||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||||
const isExplorerActive = useIsMobilePanelActive("file-explorer");
|
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||||
@@ -68,32 +67,32 @@ function useActiveCompactExplorerSidebarModel(
|
|||||||
const resolvedModel = useMemo(
|
const resolvedModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveCompactExplorerSidebarHostModel({
|
resolveCompactExplorerSidebarHostModel({
|
||||||
previous: isExplorerActive ? retainedModelRef.current : null,
|
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||||
selection,
|
selection,
|
||||||
workspace,
|
workspace,
|
||||||
isGit: checkoutQuery.data?.isGit ?? false,
|
isGit: checkoutQuery.data?.isGit ?? false,
|
||||||
}),
|
}),
|
||||||
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
|
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
if (enabled && isExplorerActive) {
|
if (enabled && isExplorerOpen) {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isExplorerActive) {
|
if (!isExplorerOpen) {
|
||||||
retainedModelRef.current = null;
|
retainedModelRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resolvedModel) {
|
if (resolvedModel) {
|
||||||
retainedModelRef.current = resolvedModel;
|
retainedModelRef.current = resolvedModel;
|
||||||
}
|
}
|
||||||
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
|
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||||
|
|
||||||
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
|
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactExplorerSidebarHostProps {
|
interface CompactExplorerSidebarHostProps {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||||
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
|
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -82,7 +81,7 @@ export function CompactExplorerSidebar({
|
|||||||
}: ExplorerSidebarProps) {
|
}: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isActive = useIsMobilePanelActive("file-explorer");
|
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -100,11 +99,11 @@ export function CompactExplorerSidebar({
|
|||||||
(reason: string) => {
|
(reason: string) => {
|
||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen: isActive,
|
isOpen,
|
||||||
});
|
});
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
},
|
},
|
||||||
[isActive, showMobileAgent],
|
[isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||||
@@ -128,7 +127,7 @@ export function CompactExplorerSidebar({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RetainedPanelActivity active={isActive}>
|
<RetainedPanelActivity active={isOpen}>
|
||||||
<MobilePanelOverlay
|
<MobilePanelOverlay
|
||||||
panel="file-explorer"
|
panel="file-explorer"
|
||||||
closeGesture={closeGesture}
|
closeGesture={closeGesture}
|
||||||
@@ -142,7 +141,7 @@ export function CompactExplorerSidebar({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isOpen={isActive}
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</MobilePanelOverlay>
|
</MobilePanelOverlay>
|
||||||
@@ -462,6 +461,7 @@ function ChangedFilesPane({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
cwd={workspaceRoot}
|
cwd={workspaceRoot}
|
||||||
enabled={isOpen}
|
enabled={isOpen}
|
||||||
|
modeScope="compact-explorer"
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
onAddToChat={canAddToChat ? addFile : undefined}
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
state={changesState}
|
state={changesState}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { formatDiffCount } from "@/git/file-header-presentation";
|
|
||||||
|
|
||||||
interface DiffStatProps {
|
interface DiffStatProps {
|
||||||
additions: number;
|
additions: number;
|
||||||
@@ -8,6 +7,15 @@ interface DiffStatProps {
|
|||||||
testID?: string;
|
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) {
|
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row} testID={testID}>
|
<View style={styles.row} testID={testID}>
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { router } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native";
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
FolderPlus,
|
||||||
|
GitBranch,
|
||||||
|
History,
|
||||||
|
Home,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +33,8 @@ import {
|
|||||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||||
} from "@/components/sidebar-resize-handle-layout";
|
} from "@/components/sidebar-resize-handle-layout";
|
||||||
import { HostPicker } from "@/components/hosts/host-picker";
|
import { HostPicker } from "@/components/hosts/host-picker";
|
||||||
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
|
||||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
@@ -31,6 +42,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
||||||
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
@@ -41,20 +54,29 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
|||||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||||
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
|
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
|
||||||
import {
|
import {
|
||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
|
buildNewWorkspaceRoute,
|
||||||
|
buildSchedulesRoute,
|
||||||
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
buildSettingsAddHostRoute,
|
||||||
buildSettingsRoute,
|
buildSettingsRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||||
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
|
import { PluginSidebarItems } from "@/plugins";
|
||||||
|
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
@@ -81,29 +103,36 @@ interface SidebarSharedProps {
|
|||||||
handleHome: () => void;
|
handleHome: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
labels: SidebarLabels;
|
labels: SidebarLabels;
|
||||||
|
newWorkspaceKeys: ShortcutKey[][] | null;
|
||||||
handleAddHost: () => void;
|
handleAddHost: () => void;
|
||||||
handleOpenHostSettings: (serverId: string) => void;
|
handleOpenHostSettings: (serverId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLabels {
|
interface SidebarLabels {
|
||||||
addProject: string;
|
addProject: string;
|
||||||
|
newWorkspace: string;
|
||||||
hosts: string;
|
hosts: string;
|
||||||
home: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
|
sessions: string;
|
||||||
|
schedules: string;
|
||||||
closeSidebar: string;
|
closeSidebar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
active: boolean;
|
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
insetsBottom: number;
|
insetsBottom: number;
|
||||||
closeSidebar: () => void;
|
closeSidebar: () => void;
|
||||||
|
handleViewMoreNavigate: () => void;
|
||||||
|
handleViewSchedulesNavigate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number;
|
insetsTop: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
handleViewMore: () => void;
|
||||||
|
handleViewSchedules: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
|
||||||
@@ -194,13 +223,25 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
router.push(buildOpenProjectRoute());
|
router.push(buildOpenProjectRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
|
router.push(buildSessionsRoute());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewSchedulesNavigate = useCallback(() => {
|
||||||
|
router.push(buildSchedulesRoute());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||||
const labels = useMemo(
|
const labels = useMemo(
|
||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
addProject: t("sidebar.actions.addProject"),
|
addProject: t("sidebar.actions.addProject"),
|
||||||
|
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||||
hosts: t("sidebar.actions.hosts"),
|
hosts: t("sidebar.actions.hosts"),
|
||||||
home: t("sidebar.actions.home"),
|
home: t("sidebar.actions.home"),
|
||||||
settings: t("sidebar.actions.settings"),
|
settings: t("sidebar.actions.settings"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
|
sessions: t("sidebar.sections.sessions"),
|
||||||
|
schedules: t("sidebar.sections.schedules"),
|
||||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
@@ -224,6 +265,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
labels,
|
labels,
|
||||||
|
newWorkspaceKeys,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
@@ -231,7 +273,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
<RetainedPanelActivity active={active}>
|
<RetainedPanelActivity active={active}>
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
{...sharedProps}
|
{...sharedProps}
|
||||||
active={active}
|
|
||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
closeSidebar={showMobileAgent}
|
closeSidebar={showMobileAgent}
|
||||||
@@ -240,6 +281,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleAddHost={handleAddHostMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
|
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
|
||||||
/>
|
/>
|
||||||
</RetainedPanelActivity>
|
</RetainedPanelActivity>
|
||||||
);
|
);
|
||||||
@@ -256,6 +299,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
handleSettings={handleSettingsDesktop}
|
handleSettings={handleSettingsDesktop}
|
||||||
handleAddHost={handleAddHostDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
|
handleViewMore={handleViewMoreNavigate}
|
||||||
|
handleViewSchedules={handleViewSchedulesNavigate}
|
||||||
/>
|
/>
|
||||||
</RetainedPanelActivity>
|
</RetainedPanelActivity>
|
||||||
);
|
);
|
||||||
@@ -441,6 +486,61 @@ function IconTooltipContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
|
||||||
|
label,
|
||||||
|
testID,
|
||||||
|
variant,
|
||||||
|
shortcutKeys,
|
||||||
|
onBeforeNavigate,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
testID: string;
|
||||||
|
variant: "header" | "compact";
|
||||||
|
shortcutKeys: ShortcutKey[][] | null;
|
||||||
|
onBeforeNavigate?: () => void;
|
||||||
|
}) {
|
||||||
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||||
|
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
||||||
|
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
||||||
|
const supportsWorkspaceMultiplicity = useHostFeature(
|
||||||
|
activeWorkspaceServerId,
|
||||||
|
"workspaceMultiplicity",
|
||||||
|
);
|
||||||
|
const canUseActiveWorkspaceContext = Boolean(
|
||||||
|
activeWorkspace &&
|
||||||
|
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onBeforeNavigate?.();
|
||||||
|
router.push(
|
||||||
|
activeWorkspaceServerId
|
||||||
|
? buildNewWorkspaceRoute(
|
||||||
|
activeWorkspace && canUseActiveWorkspaceContext
|
||||||
|
? {
|
||||||
|
serverId: activeWorkspaceServerId,
|
||||||
|
sourceDirectory: activeWorkspace.projectRootPath,
|
||||||
|
projectId: activeWorkspace.projectId,
|
||||||
|
}
|
||||||
|
: { serverId: activeWorkspaceServerId },
|
||||||
|
)
|
||||||
|
: buildNewWorkspaceRoute(),
|
||||||
|
);
|
||||||
|
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={Plus}
|
||||||
|
label={label}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={testID}
|
||||||
|
variant={variant}
|
||||||
|
shortcutKeys={shortcutKeys}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function SidebarFooter({
|
function SidebarFooter({
|
||||||
theme,
|
theme,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
@@ -504,7 +604,6 @@ function SidebarFooter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
active,
|
|
||||||
theme,
|
theme,
|
||||||
workspaceGroups,
|
workspaceGroups,
|
||||||
projectIconTargets,
|
projectIconTargets,
|
||||||
@@ -521,6 +620,7 @@ function MobileSidebar({
|
|||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
|
newWorkspaceKeys,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleHome,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
@@ -530,9 +630,25 @@ function MobileSidebar({
|
|||||||
insetsTop,
|
insetsTop,
|
||||||
insetsBottom,
|
insetsBottom,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
|
handleViewMoreNavigate,
|
||||||
|
handleViewSchedulesNavigate,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
|
||||||
|
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
|
||||||
|
|
||||||
|
const handleViewMore = useCallback(() => {
|
||||||
|
closeSidebar();
|
||||||
|
handleViewMoreNavigate();
|
||||||
|
}, [closeSidebar, handleViewMoreNavigate]);
|
||||||
|
|
||||||
|
const handleViewSchedules = useCallback(() => {
|
||||||
|
closeSidebar();
|
||||||
|
handleViewSchedulesNavigate();
|
||||||
|
}, [closeSidebar, handleViewSchedulesNavigate]);
|
||||||
|
|
||||||
const handleWorkspacePress = useCallback(() => {
|
const handleWorkspacePress = useCallback(() => {
|
||||||
closeSidebar();
|
closeSidebar();
|
||||||
@@ -555,7 +671,32 @@ function MobileSidebar({
|
|||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<WindowChromeSafeArea placement="below" />
|
<WindowChromeSafeArea placement="below" />
|
||||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
<View style={styles.sidebarHeaderGroup}>
|
||||||
|
<SidebarNewWorkspaceHeaderRow
|
||||||
|
label={labels.newWorkspace}
|
||||||
|
testID="sidebar-global-new-workspace"
|
||||||
|
variant="compact"
|
||||||
|
shortcutKeys={newWorkspaceKeys}
|
||||||
|
onBeforeNavigate={closeSidebar}
|
||||||
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={History}
|
||||||
|
label={labels.sessions}
|
||||||
|
onPress={handleViewMore}
|
||||||
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={CalendarClock}
|
||||||
|
label={labels.schedules}
|
||||||
|
onPress={handleViewSchedules}
|
||||||
|
isActive={isSchedulesActive}
|
||||||
|
testID="sidebar-schedules"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
|
||||||
|
</View>
|
||||||
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.mobileCloseButton}
|
style={styles.mobileCloseButton}
|
||||||
@@ -596,7 +737,7 @@ function MobileSidebar({
|
|||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
dragGestureHostActive={active}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -632,6 +773,7 @@ function DesktopSidebar({
|
|||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
|
newWorkspaceKeys,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleHome,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
@@ -640,9 +782,14 @@ function DesktopSidebar({
|
|||||||
handleOpenHostSettings,
|
handleOpenHostSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
active,
|
active,
|
||||||
|
handleViewMore,
|
||||||
|
handleViewSchedules,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||||
|
const pathname = usePathname();
|
||||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
|
const isSchedulesActive = pathname.includes("/schedules");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
@@ -750,7 +897,31 @@ function DesktopSidebar({
|
|||||||
) : (
|
) : (
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
)}
|
)}
|
||||||
<SidebarNavRows style={sidebarHeaderGroupStyle} />
|
<View style={sidebarHeaderGroupStyle}>
|
||||||
|
<SidebarNewWorkspaceHeaderRow
|
||||||
|
label={labels.newWorkspace}
|
||||||
|
testID="sidebar-global-new-workspace"
|
||||||
|
variant="compact"
|
||||||
|
shortcutKeys={newWorkspaceKeys}
|
||||||
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={History}
|
||||||
|
label={labels.sessions}
|
||||||
|
onPress={handleViewMore}
|
||||||
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<SidebarHeaderRow
|
||||||
|
icon={CalendarClock}
|
||||||
|
label={labels.schedules}
|
||||||
|
onPress={handleViewSchedules}
|
||||||
|
isActive={isSchedulesActive}
|
||||||
|
testID="sidebar-schedules"
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
<PluginSidebarItems />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isInitialLoad && !hasActiveHostFilter ? (
|
{isInitialLoad && !hasActiveHostFilter ? (
|
||||||
@@ -799,10 +970,45 @@ function DesktopSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WorkspacesSectionHeader() {
|
function WorkspacesSectionHeader() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||||
|
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||||
|
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
|
||||||
|
const searchButtonStyle = useCallback(
|
||||||
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
styles.workspacesHeaderIconButton,
|
||||||
|
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspacesSectionHeader}>
|
<View style={styles.workspacesSectionHeader}>
|
||||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||||
<View style={styles.workspacesSectionActions}>
|
<View style={styles.workspacesSectionActions}>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Open command center"
|
||||||
|
testID="sidebar-command-center-search"
|
||||||
|
style={searchButtonStyle}
|
||||||
|
onPress={handleSearchPress}
|
||||||
|
>
|
||||||
|
{({ hovered, pressed }) => (
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
color={
|
||||||
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
|
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<View>
|
<View>
|
||||||
@@ -868,6 +1074,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
workspacesHeaderIconButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
workspacesHeaderIconButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
|
},
|
||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ import {
|
|||||||
markdownCopyTableCellDataSet,
|
markdownCopyTableCellDataSet,
|
||||||
type MarkdownCopyInlineTag,
|
type MarkdownCopyInlineTag,
|
||||||
} from "@/assistant-selection-copy/markup";
|
} from "@/assistant-selection-copy/markup";
|
||||||
import { capAssistantMessageForRender, getUtf8ByteLength } from "./assistant-message-render-limit";
|
|
||||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||||
export type { AssistantForkTarget };
|
export type { AssistantForkTarget };
|
||||||
|
|
||||||
@@ -578,7 +577,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
interface AssistantTurnFooterProps {
|
interface AssistantTurnFooterProps {
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
durationMs?: number | null;
|
durationMs?: number;
|
||||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,8 +615,9 @@ const TIMESTAMP_REVEAL_MS = 3000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Footer rendered next to the copy button at the end of an assistant turn.
|
* Footer rendered next to the copy button at the end of an assistant turn.
|
||||||
* Shows the turn duration and swaps to the end timestamp when both are known.
|
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
||||||
* A turn without a visible start shows its end timestamp directly.
|
* or tap (native). The hidden sizer keeps the label width stable while the
|
||||||
|
* visible text swaps.
|
||||||
*/
|
*/
|
||||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||||
getContent,
|
getContent,
|
||||||
@@ -639,10 +639,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const durationLabel = useMemo(
|
const durationLabel = useMemo(
|
||||||
() =>
|
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
|
||||||
durationMs !== undefined && durationMs !== null
|
|
||||||
? `Worked for ${formatDuration(durationMs)}`
|
|
||||||
: "",
|
|
||||||
[durationMs],
|
[durationMs],
|
||||||
);
|
);
|
||||||
const timestampLabel = useMemo(
|
const timestampLabel = useMemo(
|
||||||
@@ -650,8 +647,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
[completedAt],
|
[completedAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryLabel = durationLabel || timestampLabel;
|
const canSwap = Boolean(timestampLabel);
|
||||||
const canSwap = Boolean(durationLabel && timestampLabel);
|
|
||||||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||||||
|
|
||||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||||
@@ -682,22 +678,22 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||||
/>
|
/>
|
||||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||||
{primaryLabel ? (
|
{durationLabel ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onHoverIn={handleHoverIn}
|
onHoverIn={handleHoverIn}
|
||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
accessibilityRole={canSwap ? "button" : undefined}
|
accessibilityRole={canSwap ? "button" : undefined}
|
||||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||||||
>
|
>
|
||||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||||
{/* Sizer reserves space for whichever label is longer so the
|
{/* Sizer reserves space for whichever label is longer so the
|
||||||
container width is stable across hover transitions. */}
|
container width is stable across hover transitions. */}
|
||||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||||
{showTimestamp ? timestampLabel : primaryLabel}
|
{showTimestamp ? timestampLabel : durationLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -767,13 +763,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
containerCompactBottom: {
|
containerCompactBottom: {
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
},
|
},
|
||||||
cappedNotice: {
|
|
||||||
marginTop: theme.spacing[3],
|
|
||||||
fontFamily: theme.fontFamily.ui,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontStyle: "italic",
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
imageFrame: {
|
imageFrame: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
@@ -1499,16 +1488,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
spacing = "default",
|
spacing = "default",
|
||||||
phase,
|
phase,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||||
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
|
|
||||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
||||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
||||||
const revealedMessage = useRevealedText(renderedMessage.text, phase);
|
const revealedMessage = useRevealedText(message, phase);
|
||||||
const fullMessageByteLength = useMemo(
|
|
||||||
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
|
|
||||||
[message, phase, renderedMessage.capped],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileLinkActions = useAssistantFileLinkActions();
|
const fileLinkActions = useAssistantFileLinkActions();
|
||||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||||
@@ -1987,14 +1970,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
/>
|
/>
|
||||||
</AssistantMessageBlockContainer>
|
</AssistantMessageBlockContainer>
|
||||||
))}
|
))}
|
||||||
{fullMessageByteLength !== null ? (
|
|
||||||
<Text
|
|
||||||
testID="assistant-message-capped-notice"
|
|
||||||
style={assistantMessageStylesheet.cappedNotice}
|
|
||||||
>
|
|
||||||
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostActive?: boolean;
|
dragGestureHostPresented?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectHeaderRowProps {
|
interface ProjectHeaderRowProps {
|
||||||
@@ -1540,7 +1540,7 @@ function ProjectBlock({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
useNestable,
|
useNestable,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
@@ -1565,7 +1565,7 @@ function ProjectBlock({
|
|||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
useNestable: boolean;
|
useNestable: boolean;
|
||||||
dragGestureHostActive?: boolean;
|
dragGestureHostPresented?: boolean;
|
||||||
creatingWorkspaceIds: ReadonlySet<string>;
|
creatingWorkspaceIds: ReadonlySet<string>;
|
||||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||||
@@ -1745,7 +1745,7 @@ function ProjectBlock({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={useNestable}
|
nestable={useNestable}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostActive}
|
gestureHostPresented={dragGestureHostPresented}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canToggleWorkspaces ? (
|
{canToggleWorkspaces ? (
|
||||||
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
|||||||
previous.isDragging === next.isDragging &&
|
previous.isDragging === next.isDragging &&
|
||||||
previous.dragHandleProps === next.dragHandleProps &&
|
previous.dragHandleProps === next.dragHandleProps &&
|
||||||
previous.useNestable === next.useNestable &&
|
previous.useNestable === next.useNestable &&
|
||||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
|
||||||
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
|
||||||
areProjectBlockSelectionsEqual(previous, next)
|
areProjectBlockSelectionsEqual(previous, next)
|
||||||
);
|
);
|
||||||
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostActive={dragGestureHostActive}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProjectModeList
|
<ProjectModeList
|
||||||
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
|
|||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostActive={dragGestureHostActive}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||||
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
}: {
|
}: {
|
||||||
workspaceGroups: SidebarWorkspaceGroup[];
|
workspaceGroups: SidebarWorkspaceGroup[];
|
||||||
pinnedGroups: PinnedSidebarGroups;
|
pinnedGroups: PinnedSidebarGroups;
|
||||||
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent?: ReactElement | null;
|
listHeaderComponent?: ReactElement | null;
|
||||||
sidebarFilterEmpty: boolean;
|
sidebarFilterEmpty: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostActive?: boolean;
|
dragGestureHostPresented?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const showShortcutBadges = useShowShortcutBadges();
|
const showShortcutBadges = useShowShortcutBadges();
|
||||||
const pinnedWorkspaces = useMemo(
|
const pinnedWorkspaces = useMemo(
|
||||||
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
|
|||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
parentGestureRef={parentGestureRef}
|
parentGestureRef={parentGestureRef}
|
||||||
dragGestureHostActive={dragGestureHostActive}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2072,7 +2072,7 @@ function ProjectModeList({
|
|||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
hasActiveProjectFilter,
|
hasActiveProjectFilter,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
pathname,
|
pathname,
|
||||||
hostBadgeByServerId,
|
hostBadgeByServerId,
|
||||||
supportsMultiplicityByServerId,
|
supportsMultiplicityByServerId,
|
||||||
@@ -2289,7 +2289,7 @@ function ProjectModeList({
|
|||||||
isDragging={dragState.isDragging}
|
isDragging={dragState.isDragging}
|
||||||
dragHandleProps={dragState.dragHandleProps}
|
dragHandleProps={dragState.dragHandleProps}
|
||||||
useNestable={platformIsNative}
|
useNestable={platformIsNative}
|
||||||
dragGestureHostActive={dragGestureHostActive}
|
dragGestureHostPresented={dragGestureHostPresented}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
hostBadgeByServerId={hostBadgeByServerId}
|
hostBadgeByServerId={hostBadgeByServerId}
|
||||||
@@ -2311,7 +2311,7 @@ function ProjectModeList({
|
|||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onToggleProjectCollapsed,
|
onToggleProjectCollapsed,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
projectIconByProjectViewKey,
|
projectIconByProjectViewKey,
|
||||||
selectionEnabled,
|
selectionEnabled,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
@@ -2388,7 +2388,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostActive}
|
gestureHostPresented={dragGestureHostPresented}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2411,7 +2411,7 @@ function ProjectModeList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostActive}
|
gestureHostPresented={dragGestureHostPresented}
|
||||||
containerStyle={styles.workspaceListContainer}
|
containerStyle={styles.workspaceListContainer}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedChats ? (
|
{canTogglePinnedChats ? (
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
import { router, usePathname } from "expo-router";
|
|
||||||
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
|
|
||||||
import { memo, useCallback, useMemo, type ComponentType } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
||||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
||||||
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
|
|
||||||
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
|
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
|
||||||
import {
|
|
||||||
builtinSidebarNavLabelKey,
|
|
||||||
builtinSidebarNavShortcutAction,
|
|
||||||
type BuiltinSidebarNavId,
|
|
||||||
} from "@/sidebar-nav/model";
|
|
||||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
|
||||||
import {
|
|
||||||
buildNewWorkspaceRoute,
|
|
||||||
buildSchedulesRoute,
|
|
||||||
buildSessionsRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
interface SidebarNavRowProps {
|
|
||||||
onBeforeNavigate?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarNavRowsProps extends SidebarNavRowProps {
|
|
||||||
/** Style for the group wrapper, which the sidebar owns. */
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top-level sidebar navigation, ordered and filtered by the user's
|
|
||||||
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
|
|
||||||
* wrapper — when every item is hidden.
|
|
||||||
*/
|
|
||||||
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
|
|
||||||
const { items } = useSidebarNavItems();
|
|
||||||
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
|
|
||||||
|
|
||||||
if (visibleItems.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={style}>
|
|
||||||
{visibleItems.map((item) => {
|
|
||||||
if (item.kind === "plugin") {
|
|
||||||
return (
|
|
||||||
<PluginSidebarItemRow
|
|
||||||
key={item.key}
|
|
||||||
group={item.group}
|
|
||||||
onBeforeNavigate={onBeforeNavigate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const Row = BUILTIN_ROWS[item.id];
|
|
||||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarNewWorkspaceRow = memo(function SidebarNewWorkspaceRow({
|
|
||||||
onBeforeNavigate,
|
|
||||||
}: SidebarNavRowProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("new-workspace"));
|
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
||||||
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
|
|
||||||
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
|
|
||||||
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
|
|
||||||
const supportsWorkspaceMultiplicity = useHostFeature(
|
|
||||||
activeWorkspaceServerId,
|
|
||||||
"workspaceMultiplicity",
|
|
||||||
);
|
|
||||||
const canUseActiveWorkspaceContext = Boolean(
|
|
||||||
activeWorkspace &&
|
|
||||||
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onBeforeNavigate?.();
|
|
||||||
router.push(
|
|
||||||
activeWorkspaceServerId
|
|
||||||
? buildNewWorkspaceRoute(
|
|
||||||
activeWorkspace && canUseActiveWorkspaceContext
|
|
||||||
? {
|
|
||||||
serverId: activeWorkspaceServerId,
|
|
||||||
sourceDirectory: activeWorkspace.projectRootPath,
|
|
||||||
projectId: activeWorkspace.projectId,
|
|
||||||
}
|
|
||||||
: { serverId: activeWorkspaceServerId },
|
|
||||||
)
|
|
||||||
: buildNewWorkspaceRoute(),
|
|
||||||
);
|
|
||||||
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={Plus}
|
|
||||||
label={t(builtinSidebarNavLabelKey("new-workspace"))}
|
|
||||||
onPress={handlePress}
|
|
||||||
testID="sidebar-global-new-workspace"
|
|
||||||
variant="compact"
|
|
||||||
shortcutKeys={shortcutKeys}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onBeforeNavigate?.();
|
|
||||||
router.push(buildSessionsRoute());
|
|
||||||
}, [onBeforeNavigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={History}
|
|
||||||
label={t(builtinSidebarNavLabelKey("history"))}
|
|
||||||
onPress={handlePress}
|
|
||||||
isActive={pathname.includes("/sessions")}
|
|
||||||
testID="sidebar-sessions"
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
|
|
||||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onBeforeNavigate?.();
|
|
||||||
setCommandCenterOpen(true);
|
|
||||||
}, [onBeforeNavigate, setCommandCenterOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={Search}
|
|
||||||
label={t(builtinSidebarNavLabelKey("search"))}
|
|
||||||
onPress={handlePress}
|
|
||||||
testID="sidebar-search"
|
|
||||||
variant="compact"
|
|
||||||
shortcutKeys={shortcutKeys}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SidebarSchedulesRow({ onBeforeNavigate }: SidebarNavRowProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
onBeforeNavigate?.();
|
|
||||||
router.push(buildSchedulesRoute());
|
|
||||||
}, [onBeforeNavigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarHeaderRow
|
|
||||||
icon={CalendarClock}
|
|
||||||
label={t(builtinSidebarNavLabelKey("schedules"))}
|
|
||||||
onPress={handlePress}
|
|
||||||
isActive={pathname.includes("/schedules")}
|
|
||||||
testID="sidebar-schedules"
|
|
||||||
variant="compact"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps>> = {
|
|
||||||
"new-workspace": SidebarNewWorkspaceRow,
|
|
||||||
history: SidebarHistoryRow,
|
|
||||||
search: SidebarSearchRow,
|
|
||||||
schedules: SidebarSchedulesRow,
|
|
||||||
};
|
|
||||||
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
|
|||||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||||
sidebarFilterEmpty?: boolean;
|
sidebarFilterEmpty?: boolean;
|
||||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||||
dragGestureHostActive?: boolean;
|
dragGestureHostPresented?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarStatusWorkspaceList({
|
export function SidebarStatusWorkspaceList({
|
||||||
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty = false,
|
sidebarFilterEmpty = false,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
dragGestureHostActive,
|
dragGestureHostPresented,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedWorkspaceGroupKeys,
|
(state) => state.collapsedWorkspaceGroupKeys,
|
||||||
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
|
|||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={platformIsNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
gestureHostPresented={dragGestureHostActive}
|
gestureHostPresented={dragGestureHostPresented}
|
||||||
/>
|
/>
|
||||||
{canTogglePinnedWorkspaces ? (
|
{canTogglePinnedWorkspaces ? (
|
||||||
<SidebarGroupToggleRow
|
<SidebarGroupToggleRow
|
||||||
|
|||||||
@@ -131,46 +131,6 @@ describe("EditingTextInputNative", () => {
|
|||||||
expect(document.activeElement).toBe(container?.querySelector("input"));
|
expect(document.activeElement).toBe(container?.querySelector("input"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the replacement input when focus is requested before a cleared input remounts", () => {
|
|
||||||
const handleRef = createRef<EditingTextInputHandle>();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
|
|
||||||
});
|
|
||||||
const originalInput = container?.querySelector("input");
|
|
||||||
if (!originalInput) throw new Error("Expected native input");
|
|
||||||
const originalFocus = vi.spyOn(originalInput, "focus");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
handleRef.current?.replaceText("");
|
|
||||||
handleRef.current?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
const replacementInput = container?.querySelector("input");
|
|
||||||
expect(replacementInput).not.toBe(originalInput);
|
|
||||||
expect(originalFocus).not.toHaveBeenCalled();
|
|
||||||
expect(document.activeElement).toBe(replacementInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops a pending focus restore when blur is requested before a cleared input remounts", () => {
|
|
||||||
const handleRef = createRef<EditingTextInputHandle>();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<EditingTextInput ref={handleRef} initialValue="stale" />);
|
|
||||||
});
|
|
||||||
const originalInput = container?.querySelector("input");
|
|
||||||
if (!originalInput) throw new Error("Expected native input");
|
|
||||||
Object.assign(originalInput, { isFocused: () => true });
|
|
||||||
originalInput.focus();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
handleRef.current?.replaceText("");
|
|
||||||
handleRef.current?.blur();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document.activeElement).not.toBe(container?.querySelector("input"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates textRef and text when replaceText receives non-empty text", () => {
|
it("updates textRef and text when replaceText receives non-empty text", () => {
|
||||||
const handleRef = createRef<EditingTextInputHandle>();
|
const handleRef = createRef<EditingTextInputHandle>();
|
||||||
|
|
||||||
|
|||||||
@@ -35,54 +35,31 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
const inputRef = useRef<NativeInput | null>(null);
|
const inputRef = useRef<NativeInput | null>(null);
|
||||||
const initialTextRef = useRef(initialValue);
|
const initialTextRef = useRef(initialValue);
|
||||||
const textRef = useRef(initialTextRef.current);
|
const textRef = useRef(initialTextRef.current);
|
||||||
// Clearing swaps the native input for a fresh instance (see replaceText).
|
const restoreFocusAfterResetRef = useRef(false);
|
||||||
// Until React commits that swap, `inputRef` still points at the doomed
|
const [nativeInputRevision, setNativeInputRevision] = useState(0);
|
||||||
// instance: focusing it asks Android for the keyboard and then tears the
|
|
||||||
// focused view down, which cancels the show. Fabric also runs view
|
|
||||||
// commands before mount items, so a focus command sent alongside the swap
|
|
||||||
// reaches a view that is not attached yet and the IME ignores it. Focus is
|
|
||||||
// therefore carried by the replacement's `autoFocus`, which native applies
|
|
||||||
// once the view is attached.
|
|
||||||
const isAwaitingReplacementRef = useRef(false);
|
|
||||||
const [replacement, setReplacement] = useState({ revision: 0, autoFocus: false });
|
|
||||||
|
|
||||||
const assignInputRef = useCallback((input: NativeInput | null) => {
|
const assignInputRef = useCallback((input: NativeInput | null) => {
|
||||||
inputRef.current = input;
|
inputRef.current = input;
|
||||||
if (input) isAwaitingReplacementRef.current = false;
|
if (!input || !restoreFocusAfterResetRef.current) return;
|
||||||
}, []);
|
restoreFocusAfterResetRef.current = false;
|
||||||
|
input.focus();
|
||||||
const setReplacementFocus = useCallback((autoFocus: boolean) => {
|
|
||||||
setReplacement((current) => ({ ...current, autoFocus }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => {
|
focus: () => inputRef.current?.focus(),
|
||||||
if (isAwaitingReplacementRef.current) {
|
blur: () => inputRef.current?.blur(),
|
||||||
setReplacementFocus(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
inputRef.current?.focus();
|
|
||||||
},
|
|
||||||
blur: () => {
|
|
||||||
if (isAwaitingReplacementRef.current) {
|
|
||||||
setReplacementFocus(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
inputRef.current?.blur();
|
|
||||||
},
|
|
||||||
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
||||||
getText: () => textRef.current,
|
getText: () => textRef.current,
|
||||||
replaceText: (nextText, selection) => {
|
replaceText: (nextText, selection) => {
|
||||||
textRef.current = nextText;
|
textRef.current = nextText;
|
||||||
if (nextText === "") {
|
if (nextText === "") {
|
||||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
restoreFocusAfterResetRef.current = inputRef.current?.isFocused?.() ?? false;
|
||||||
if (inputRef.current?.replaceText) {
|
if (inputRef.current?.replaceText) {
|
||||||
inputRef.current.replaceText(nextText, selection);
|
inputRef.current.replaceText(nextText, selection);
|
||||||
} else {
|
} else {
|
||||||
inputRef.current?.clear?.();
|
inputRef.current?.clear?.();
|
||||||
}
|
}
|
||||||
isAwaitingReplacementRef.current = true;
|
setNativeInputRevision((revision) => revision + 1);
|
||||||
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inputRef.current?.replaceText) {
|
if (inputRef.current?.replaceText) {
|
||||||
@@ -116,14 +93,11 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
[onPasteError, onPasteImages],
|
[onPasteError, onPasteImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const autoFocus = replacement.revision === 0 ? props.autoFocus : replacement.autoFocus;
|
|
||||||
|
|
||||||
if (onPasteImages || onPasteError) {
|
if (onPasteImages || onPasteError) {
|
||||||
return (
|
return (
|
||||||
<PasteInput
|
<PasteInput
|
||||||
{...props}
|
{...props}
|
||||||
autoFocus={autoFocus}
|
key={nativeInputRevision}
|
||||||
key={replacement.revision}
|
|
||||||
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
ref={assignInputRef as React.Ref<PasteTextInputInstance>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
@@ -135,8 +109,7 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
return (
|
return (
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
{...props}
|
{...props}
|
||||||
autoFocus={autoFocus}
|
key={nativeInputRevision}
|
||||||
key={replacement.revision}
|
|
||||||
ref={assignInputRef as unknown as React.Ref<never>}
|
ref={assignInputRef as unknown as React.Ref<never>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
@@ -146,8 +119,7 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...props}
|
{...props}
|
||||||
autoFocus={autoFocus}
|
key={nativeInputRevision}
|
||||||
key={replacement.revision}
|
|
||||||
ref={assignInputRef as React.Ref<TextInput>}
|
ref={assignInputRef as React.Ref<TextInput>}
|
||||||
defaultValue={textRef.current}
|
defaultValue={textRef.current}
|
||||||
onChangeText={handleChangeText}
|
onChangeText={handleChangeText}
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|||||||
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
|
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
|
||||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||||
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
|
||||||
import { usePluginClientSlashCommands } from "@/plugins/client-slash-commands";
|
|
||||||
import {
|
|
||||||
executePluginClientSlashCommand,
|
|
||||||
resolvePluginClientSlashCommand,
|
|
||||||
} from "@/plugins/client-slash-commands/model";
|
|
||||||
import {
|
import {
|
||||||
useHostRuntimeAgentDirectoryStatus,
|
useHostRuntimeAgentDirectoryStatus,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
@@ -1273,11 +1268,6 @@ function ComposerContentImpl({
|
|||||||
onChangeAttachments: setSelectedAttachments,
|
onChangeAttachments: setSelectedAttachments,
|
||||||
anchorRef: attachButtonRef,
|
anchorRef: attachButtonRef,
|
||||||
});
|
});
|
||||||
const pluginClientSlashCommands = usePluginClientSlashCommands({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
|
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
|
||||||
const keyboardHandlerIdRef = useRef(
|
const keyboardHandlerIdRef = useRef(
|
||||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||||
@@ -1329,27 +1319,6 @@ function ComposerContentImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runPluginClientSlashCommand = useCallback(
|
|
||||||
(resolved: { command: (typeof pluginClientSlashCommands)[number]; args: string }): boolean => {
|
|
||||||
if (blurOnSubmit) messageInputRef.current?.blur();
|
|
||||||
clearDraft("sent");
|
|
||||||
replaceUserInput("");
|
|
||||||
setSelectedAttachments([]);
|
|
||||||
resetSuppression();
|
|
||||||
setSendError(null);
|
|
||||||
executePluginClientSlashCommand({
|
|
||||||
command: resolved.command,
|
|
||||||
args: resolved.args,
|
|
||||||
onError(error) {
|
|
||||||
console.error("[Composer] Failed to run plugin client slash command:", error);
|
|
||||||
toastErrorRef.current(error instanceof Error ? error.message : String(error));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments],
|
|
||||||
);
|
|
||||||
|
|
||||||
const autocomplete = useAgentAutocomplete({
|
const autocomplete = useAgentAutocomplete({
|
||||||
userInput,
|
userInput,
|
||||||
cursorIndex,
|
cursorIndex,
|
||||||
@@ -1359,7 +1328,6 @@ function ComposerContentImpl({
|
|||||||
draftConfig: commandDraftConfig,
|
draftConfig: commandDraftConfig,
|
||||||
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
|
||||||
onClientSlashCommand: runClientSlashCommand,
|
onClientSlashCommand: runClientSlashCommand,
|
||||||
pluginClientSlashCommands,
|
|
||||||
onAutocompleteApplied: () => {
|
onAutocompleteApplied: () => {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
@@ -1627,12 +1595,6 @@ function ComposerContentImpl({
|
|||||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pluginSlashCommand = resolvePluginClientSlashCommand({
|
|
||||||
text: payload.text,
|
|
||||||
hasAttachments: outgoingAttachments.length > 0,
|
|
||||||
commands: pluginClientSlashCommands,
|
|
||||||
});
|
|
||||||
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
|
|
||||||
|
|
||||||
if (blurOnSubmit) {
|
if (blurOnSubmit) {
|
||||||
messageInputRef.current?.blur();
|
messageInputRef.current?.blur();
|
||||||
@@ -1644,8 +1606,6 @@ function ComposerContentImpl({
|
|||||||
blurOnSubmit,
|
blurOnSubmit,
|
||||||
buildOutgoingAttachments,
|
buildOutgoingAttachments,
|
||||||
runClientSlashCommand,
|
runClientSlashCommand,
|
||||||
pluginClientSlashCommands,
|
|
||||||
runPluginClientSlashCommand,
|
|
||||||
sendMessageWithContent,
|
sendMessageWithContent,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1900,22 +1860,9 @@ function ComposerContentImpl({
|
|||||||
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pluginSlashCommand = resolvePluginClientSlashCommand({
|
|
||||||
text: payload.text,
|
|
||||||
hasAttachments: outgoingAttachments.length > 0,
|
|
||||||
commands: pluginClientSlashCommands,
|
|
||||||
});
|
|
||||||
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
|
|
||||||
queueMessage(payload.text, outgoingAttachments);
|
queueMessage(payload.text, outgoingAttachments);
|
||||||
},
|
},
|
||||||
[
|
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
|
||||||
attachments,
|
|
||||||
buildOutgoingAttachments,
|
|
||||||
pluginClientSlashCommands,
|
|
||||||
queueMessage,
|
|
||||||
runClientSlashCommand,
|
|
||||||
runPluginClientSlashCommand,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
|
|||||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||||
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
||||||
import {
|
import {
|
||||||
consumeForcedTimelineTailReplacement,
|
|
||||||
type TimelineDeliveryMode,
|
type TimelineDeliveryMode,
|
||||||
type TimelineResponsePayload,
|
type TimelineResponsePayload,
|
||||||
type ViewedTimelineOwner,
|
type ViewedTimelineOwner,
|
||||||
@@ -60,6 +59,15 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
|
|||||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||||
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
|
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
|
||||||
|
|
||||||
|
function consumeForcedTimelineTailReplacement(
|
||||||
|
payload: TimelineResponsePayload,
|
||||||
|
replacements: Set<string>,
|
||||||
|
): TimelineResponsePayload {
|
||||||
|
if (payload.direction !== "tail") return payload;
|
||||||
|
if (!replacements.delete(payload.agentId)) return payload;
|
||||||
|
return { ...payload, reset: true };
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -1,36 +1,49 @@
|
|||||||
import { Text, View } from "react-native";
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { SourceEditor } from "@/source-editor/source-editor";
|
||||||
import type { HighlightStyle } from "@getpaseo/highlight";
|
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import type { FileEditorModel } from "./model";
|
import type { FileEditorModel } from "./model";
|
||||||
|
|
||||||
export function FileEditorView(_props: {
|
export function FileEditorView(props: {
|
||||||
model: FileEditorModel;
|
model: FileEditorModel;
|
||||||
filename: string;
|
filename: string;
|
||||||
location: WorkspaceFileLocation;
|
location: WorkspaceFileLocation;
|
||||||
navigationRevision: number;
|
navigationRevision: number;
|
||||||
vimEnabled: boolean;
|
vimEnabled: boolean;
|
||||||
theme: {
|
theme: SourceEditorTheme;
|
||||||
background: string;
|
|
||||||
foreground: string;
|
|
||||||
foregroundMuted: string;
|
|
||||||
border: string;
|
|
||||||
selection: string;
|
|
||||||
monoFont: string;
|
|
||||||
codeFontSize: number;
|
|
||||||
syntax: Record<HighlightStyle, string>;
|
|
||||||
};
|
|
||||||
onCursorChange(position: { line: number; column: number }): void;
|
onCursorChange(position: { line: number; column: number }): void;
|
||||||
onVimModeChange(mode: string | null): void;
|
onVimModeChange(mode: string | null): void;
|
||||||
}) {
|
}) {
|
||||||
|
const snapshot = useSyncExternalStore(
|
||||||
|
props.model.subscribe,
|
||||||
|
props.model.getSnapshot,
|
||||||
|
props.model.getSnapshot,
|
||||||
|
);
|
||||||
|
const selectionTarget = useMemo(
|
||||||
|
() =>
|
||||||
|
props.location.lineStart
|
||||||
|
? {
|
||||||
|
lineStart: props.location.lineStart,
|
||||||
|
lineEnd: props.location.lineEnd,
|
||||||
|
revision: props.navigationRevision,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[props.location.lineEnd, props.location.lineStart, props.navigationRevision],
|
||||||
|
);
|
||||||
|
const handleChange = useCallback((document: string) => props.model.edit(document), [props.model]);
|
||||||
|
const handleSave = useCallback(() => void props.model.save(), [props.model]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<SourceEditor
|
||||||
<Text style={styles.text}>Source editing is available on web and desktop.</Text>
|
document={snapshot.content}
|
||||||
</View>
|
filename={props.filename}
|
||||||
|
selectionTarget={selectionTarget}
|
||||||
|
vimEnabled={props.vimEnabled}
|
||||||
|
theme={props.theme}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCursorChange={props.onCursorChange}
|
||||||
|
onVimModeChange={props.onVimModeChange}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
||||||
text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
|
||||||
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
|
||||||
import { getCM, vim } from "@replit/codemirror-vim";
|
|
||||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
|
||||||
import type { FileEditorModel } from "./model";
|
|
||||||
import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web";
|
|
||||||
|
|
||||||
interface FileEditorViewProps {
|
|
||||||
model: FileEditorModel;
|
|
||||||
filename: string;
|
|
||||||
location: WorkspaceFileLocation;
|
|
||||||
navigationRevision: number;
|
|
||||||
vimEnabled: boolean;
|
|
||||||
theme: EditorVisualTheme;
|
|
||||||
onCursorChange(position: { line: number; column: number }): void;
|
|
||||||
onVimModeChange(mode: string | null): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const languageCompartment = new Compartment();
|
|
||||||
const wrappingCompartment = new Compartment();
|
|
||||||
const themeCompartment = new Compartment();
|
|
||||||
const vimCompartment = new Compartment();
|
|
||||||
|
|
||||||
function wrappingForFile(filename: string) {
|
|
||||||
return isRenderedMarkdownFile(filename) ? EditorView.lineWrapping : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileEditorView({
|
|
||||||
model,
|
|
||||||
filename,
|
|
||||||
location,
|
|
||||||
navigationRevision,
|
|
||||||
vimEnabled,
|
|
||||||
theme,
|
|
||||||
onCursorChange,
|
|
||||||
onVimModeChange,
|
|
||||||
}: FileEditorViewProps) {
|
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
|
||||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
|
||||||
const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content });
|
|
||||||
const onCursorChangeRef = useRef(onCursorChange);
|
|
||||||
onCursorChangeRef.current = onCursorChange;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hostRef.current) return;
|
|
||||||
const values = initial.current;
|
|
||||||
const view = new EditorView({
|
|
||||||
parent: hostRef.current,
|
|
||||||
state: EditorState.create({
|
|
||||||
doc: values.content,
|
|
||||||
extensions: [
|
|
||||||
vimCompartment.of(values.vimEnabled ? vim() : []),
|
|
||||||
...editorBaseExtensions(() => void values.model.save()),
|
|
||||||
languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []),
|
|
||||||
wrappingCompartment.of(wrappingForFile(values.filename)),
|
|
||||||
themeCompartment.of(editorTheme(values.theme)),
|
|
||||||
EditorView.updateListener.of((update) => {
|
|
||||||
if (
|
|
||||||
update.docChanged &&
|
|
||||||
!update.transactions.some((tr) => tr.annotation(remoteUpdate))
|
|
||||||
) {
|
|
||||||
const { lineSeparator } = values.model.getSnapshot();
|
|
||||||
values.model.edit(update.state.doc.sliceString(0, undefined, lineSeparator));
|
|
||||||
}
|
|
||||||
if (update.selectionSet || update.docChanged) {
|
|
||||||
const head = update.state.selection.main.head;
|
|
||||||
const line = update.state.doc.lineAt(head);
|
|
||||||
onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 });
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
viewRef.current = view;
|
|
||||||
onCursorChangeRef.current({ line: 1, column: 1 });
|
|
||||||
return () => {
|
|
||||||
view.destroy();
|
|
||||||
viewRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
if (!view) return;
|
|
||||||
const document = view.state.toText(snapshot.content);
|
|
||||||
if (view.state.doc.eq(document)) return;
|
|
||||||
const head = Math.min(view.state.selection.main.head, document.length);
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: document },
|
|
||||||
selection: { anchor: head },
|
|
||||||
annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)],
|
|
||||||
});
|
|
||||||
}, [snapshot.content]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
if (!view || !location.lineStart) return;
|
|
||||||
const lineStart = Math.min(location.lineStart, view.state.doc.lines);
|
|
||||||
const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines);
|
|
||||||
const from = view.state.doc.line(lineStart).from;
|
|
||||||
const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to;
|
|
||||||
view.dispatch({
|
|
||||||
selection: { anchor: from, head: lineEnd > lineStart ? to : from },
|
|
||||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
|
||||||
});
|
|
||||||
}, [location.lineEnd, location.lineStart, navigationRevision]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
viewRef.current?.dispatch({
|
|
||||||
effects: [
|
|
||||||
languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []),
|
|
||||||
wrappingCompartment.reconfigure(wrappingForFile(filename)),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, [filename]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) });
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const view = viewRef.current;
|
|
||||||
if (!view) return;
|
|
||||||
view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) });
|
|
||||||
if (!vimEnabled) {
|
|
||||||
onVimModeChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cm = getCM(view);
|
|
||||||
if (!cm) return;
|
|
||||||
function handleModeChange(event: { mode?: string }) {
|
|
||||||
onVimModeChange((event.mode ?? "normal").toUpperCase());
|
|
||||||
}
|
|
||||||
cm.on("vim-mode-change", handleModeChange);
|
|
||||||
onVimModeChange("NORMAL");
|
|
||||||
return () => cm.off("vim-mode-change", handleModeChange);
|
|
||||||
}, [onVimModeChange, vimEnabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={hostRef}
|
|
||||||
data-pmono=""
|
|
||||||
data-testid="file-source-editor"
|
|
||||||
aria-label={`Source editor for ${filename}`}
|
|
||||||
style={HOST_STYLE}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteUpdate = Annotation.define<boolean>();
|
|
||||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
|
||||||
@@ -22,7 +22,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
|||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useLiveFile } from "./live-file/hook";
|
import { useLiveFile } from "./live-file/hook";
|
||||||
import { useFilePreview } from "./preview-lifecycle/hook";
|
import { useFilePreview } from "./preview-lifecycle/hook";
|
||||||
@@ -329,10 +328,7 @@ function isEditableTextFile(input: {
|
|||||||
supportsEditing: boolean;
|
supportsEditing: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
isWeb &&
|
input.supportsEditing && input.preview?.kind === "text" && input.preview.size <= 1024 * 1024,
|
||||||
input.supportsEditing &&
|
|
||||||
input.preview?.kind === "text" &&
|
|
||||||
input.preview.size <= 1024 * 1024,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
|
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
|
||||||
import { selectSourcePresentation } from "./presentation";
|
import { selectSourcePresentation } from "./presentation";
|
||||||
|
|
||||||
interface FileSourceViewProps {
|
interface FileSourceViewProps {
|
||||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
|||||||
location: WorkspaceFileLocation;
|
location: WorkspaceFileLocation;
|
||||||
navigationRevision: number;
|
navigationRevision: number;
|
||||||
size: number;
|
size: number;
|
||||||
theme: EditorVisualTheme;
|
theme: SourceEditorTheme;
|
||||||
tooLargeMessage: string;
|
tooLargeMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Compartment, EditorState } from "@codemirror/state";
|
import { Compartment, EditorState } from "@codemirror/state";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { getLanguageForFile } from "@getpaseo/highlight";
|
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||||
|
import type { SourceEditorTheme } from "@/source-editor/contract";
|
||||||
|
import { sourceEditorTheme } from "@/source-editor/codemirror/configuration";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import type { EditorVisualTheme } from "../editor/extensions.web";
|
|
||||||
import { editorTheme } from "../editor/extensions.web";
|
|
||||||
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||||
|
|
||||||
interface FileSourceViewProps {
|
interface FileSourceViewProps {
|
||||||
@@ -13,7 +13,7 @@ interface FileSourceViewProps {
|
|||||||
location: WorkspaceFileLocation;
|
location: WorkspaceFileLocation;
|
||||||
navigationRevision: number;
|
navigationRevision: number;
|
||||||
size: number;
|
size: number;
|
||||||
theme: EditorVisualTheme;
|
theme: SourceEditorTheme;
|
||||||
tooLargeMessage: string;
|
tooLargeMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ function ReadonlyCodeMirror({
|
|||||||
languageCompartment.of(
|
languageCompartment.of(
|
||||||
languageFor({ filename: values.filename, presentation: values.presentation }),
|
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||||
),
|
),
|
||||||
themeCompartment.of(editorTheme(values.theme)),
|
themeCompartment.of(sourceEditorTheme(values.theme)),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -97,7 +97,7 @@ function ReadonlyCodeMirror({
|
|||||||
viewRef.current?.dispatch({
|
viewRef.current?.dispatch({
|
||||||
effects: [
|
effects: [
|
||||||
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||||
themeCompartment.reconfigure(editorTheme(theme)),
|
themeCompartment.reconfigure(sourceEditorTheme(theme)),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}, [filename, presentation, theme]);
|
}, [filename, presentation, theme]);
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
prPanePipelineQueryKey,
|
prPanePipelineQueryKey,
|
||||||
prPaneTimelineQueryKey,
|
prPaneTimelineQueryKey,
|
||||||
} from "@/git/pull-request-panel/query-keys";
|
} from "@/git/pull-request-panel/query-keys";
|
||||||
import {
|
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||||
resetWorkingDiffComparisons,
|
|
||||||
resolveWorkingDiffComparison,
|
|
||||||
selectWorkingDiffComparison,
|
|
||||||
} from "@/git/working-diff-comparison";
|
|
||||||
import {
|
import {
|
||||||
applyCheckoutStatusUpdateFromEvent,
|
applyCheckoutStatusUpdateFromEvent,
|
||||||
ensureCheckoutStatus,
|
ensureCheckoutStatus,
|
||||||
@@ -91,12 +87,10 @@ function checkoutStatusUpdate(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectBaseComparison(isDirtyAtSelection: boolean): void {
|
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
|
||||||
selectWorkingDiffComparison({
|
useReviewDraftStore.getState().setDiffModeOverride({
|
||||||
serverId,
|
scopeKey: "review:scope",
|
||||||
cwd,
|
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
|
||||||
comparison: "base",
|
|
||||||
isDirty: isDirtyAtSelection,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +99,7 @@ function createQueryClient(): QueryClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetWorkingDiffComparisons();
|
resetReviewDraftStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchCheckoutStatus", () => {
|
describe("fetchCheckoutStatus", () => {
|
||||||
@@ -119,14 +113,13 @@ describe("fetchCheckoutStatus", () => {
|
|||||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
|
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
|
||||||
selectBaseComparison(true);
|
setDiffModeOverride(true);
|
||||||
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
||||||
|
|
||||||
await fetchCheckoutStatus({ client, serverId, cwd });
|
await fetchCheckoutStatus({ client, serverId, cwd });
|
||||||
|
|
||||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
|
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,9 +233,9 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
|||||||
).toBe("unauthenticated");
|
).toBe("unauthenticated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
|
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
selectBaseComparison(false);
|
setDiffModeOverride(false);
|
||||||
|
|
||||||
applyCheckoutStatusUpdateFromEvent({
|
applyCheckoutStatusUpdateFromEvent({
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -250,12 +243,12 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
|||||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
|
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
selectBaseComparison(true);
|
setDiffModeOverride(true);
|
||||||
|
|
||||||
applyCheckoutStatusUpdateFromEvent({
|
applyCheckoutStatusUpdateFromEvent({
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -263,7 +256,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
|||||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
|
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
|
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
invalidatePrPaneTimelineForCheckout,
|
invalidatePrPaneTimelineForCheckout,
|
||||||
} from "@/git/query-keys";
|
} from "@/git/query-keys";
|
||||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
import { expireStaleDiffModeOverrides } from "@/review/store";
|
||||||
|
|
||||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||||
@@ -31,7 +31,7 @@ export async function fetchCheckoutStatus({
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}): Promise<CheckoutStatusPayload> {
|
}): Promise<CheckoutStatusPayload> {
|
||||||
const payload = await client.getCheckoutStatus(cwd);
|
const payload = await client.getCheckoutStatus(cwd);
|
||||||
expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export function applyCheckoutStatusUpdateFromEvent({
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||||
});
|
});
|
||||||
expireWorkingDiffComparisons({
|
expireStaleDiffModeOverrides({
|
||||||
serverId,
|
serverId,
|
||||||
cwd: payload.cwd,
|
cwd: payload.cwd,
|
||||||
isDirty: payload.isGit && payload.isDirty,
|
isDirty: payload.isGit && payload.isDirty,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ interface DocumentFileHeaderProps {
|
|||||||
mode: DiffDocumentProps["mode"];
|
mode: DiffDocumentProps["mode"];
|
||||||
onToggleFile: (path: string) => void;
|
onToggleFile: (path: string) => void;
|
||||||
onSelectPath: (path: string) => void;
|
onSelectPath: (path: string) => void;
|
||||||
canvasRendered?: boolean;
|
|
||||||
onActiveChange?: (active: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||||
@@ -19,8 +17,6 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
mode,
|
mode,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
canvasRendered = false,
|
|
||||||
onActiveChange,
|
|
||||||
}: DocumentFileHeaderProps) {
|
}: DocumentFileHeaderProps) {
|
||||||
const activate = useCallback(
|
const activate = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
@@ -51,8 +47,6 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
onDuplicate={working?.onDuplicate}
|
onDuplicate={working?.onDuplicate}
|
||||||
onRevert={working?.onRevert}
|
onRevert={working?.onRevert}
|
||||||
testID={`diff-file-${file.fileIndex}`}
|
testID={`diff-file-${file.fileIndex}`}
|
||||||
canvasRendered={canvasRendered}
|
|
||||||
onActiveChange={onActiveChange}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, documentFileHeaderPropsEqual);
|
}, documentFileHeaderPropsEqual);
|
||||||
@@ -90,8 +84,6 @@ function documentFileHeaderIdentityMatches(
|
|||||||
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
||||||
previous.onToggleFile !== next.onToggleFile ||
|
previous.onToggleFile !== next.onToggleFile ||
|
||||||
previous.onSelectPath !== next.onSelectPath ||
|
previous.onSelectPath !== next.onSelectPath ||
|
||||||
previous.canvasRendered !== next.canvasRendered ||
|
|
||||||
previous.onActiveChange !== next.onActiveChange ||
|
|
||||||
previous.mode.kind !== next.mode.kind
|
previous.mode.kind !== next.mode.kind
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
diffHeaderViewportKey,
|
|
||||||
diffInteractionWindowTop,
|
|
||||||
resolveVisibleFileSections,
|
|
||||||
} from "./header-layout";
|
|
||||||
import type { DiffFileSection } from "./types";
|
|
||||||
|
|
||||||
describe("diff header viewport", () => {
|
|
||||||
it("keeps a many-file document bounded by the viewport", () => {
|
|
||||||
const files = Array.from({ length: 2_000 }, (_, index) => collapsedFile(index));
|
|
||||||
|
|
||||||
const window = resolveVisibleFileSections({
|
|
||||||
files,
|
|
||||||
scrollTop: 30_000,
|
|
||||||
viewportHeight: 600,
|
|
||||||
overscan: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.files.length).toBeLessThanOrEqual(61);
|
|
||||||
expect(window.sticky?.file.fileIndex).toBe(1_000);
|
|
||||||
expect(window.sticky?.y).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pins an expanded file whose original header is outside overscan", () => {
|
|
||||||
const files = [expandedFile(0, 4_000), collapsedFile(1, 4_030)];
|
|
||||||
|
|
||||||
const window = resolveVisibleFileSections({
|
|
||||||
files,
|
|
||||||
scrollTop: 2_000,
|
|
||||||
viewportHeight: 600,
|
|
||||||
overscan: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.files.map((file) => file.path)).toEqual(["file-0.ts"]);
|
|
||||||
expect(window.sticky).toEqual({ file: files[0], y: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pushes the pinned header out one pixel at a time", () => {
|
|
||||||
const files = [expandedFile(0, 100), collapsedFile(1, 130)];
|
|
||||||
|
|
||||||
expect(
|
|
||||||
resolveVisibleFileSections({ files, scrollTop: 129, viewportHeight: 100, overscan: 0 }).sticky
|
|
||||||
?.y,
|
|
||||||
).toBe(-29);
|
|
||||||
expect(
|
|
||||||
resolveVisibleFileSections({ files, scrollTop: 130, viewportHeight: 100, overscan: 0 }).sticky
|
|
||||||
?.y,
|
|
||||||
).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reuses a fixed sticky header until its visible layout changes", () => {
|
|
||||||
const files = [section(0, 0, 4_000, false), section(1, 4_000, 4_030, true)];
|
|
||||||
const keyAt = (scrollTop: number) =>
|
|
||||||
diffHeaderViewportKey({ files, scrollTop, viewportHeight: 600, activePath: null });
|
|
||||||
|
|
||||||
expect(keyAt(100)).toBe(keyAt(2_000));
|
|
||||||
expect(keyAt(3_985)).not.toBe(keyAt(2_000));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("diff interaction window", () => {
|
|
||||||
it("retains one shell window across two viewport heights", () => {
|
|
||||||
expect(diffInteractionWindowTop(0, 500)).toBe(0);
|
|
||||||
expect(diffInteractionWindowTop(999, 500)).toBe(0);
|
|
||||||
expect(diffInteractionWindowTop(1_000, 500)).toBe(1_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function collapsedFile(fileIndex: number, top = fileIndex * 30): DiffFileSection {
|
|
||||||
return section(fileIndex, top, top + 30, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandedFile(fileIndex: number, bodyHeight: number): DiffFileSection {
|
|
||||||
const top = fileIndex === 0 ? 0 : bodyHeight - 30;
|
|
||||||
return section(fileIndex, top, top + 30 + bodyHeight, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(
|
|
||||||
fileIndex: number,
|
|
||||||
top: number,
|
|
||||||
bottom: number,
|
|
||||||
isCollapsed: boolean,
|
|
||||||
): DiffFileSection {
|
|
||||||
const path = `file-${fileIndex}.ts`;
|
|
||||||
return {
|
|
||||||
file: {
|
|
||||||
path,
|
|
||||||
oldPath: undefined,
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
isNew: false,
|
|
||||||
isDeleted: false,
|
|
||||||
hunks: [],
|
|
||||||
},
|
|
||||||
fileIndex,
|
|
||||||
path,
|
|
||||||
top,
|
|
||||||
headerHeight: 30,
|
|
||||||
bodyTop: top + 30,
|
|
||||||
bodyHeight: bottom - top - 30,
|
|
||||||
bottom,
|
|
||||||
gutterWidth: 30,
|
|
||||||
contentWidth: 300,
|
|
||||||
rowStart: 0,
|
|
||||||
rowEnd: 0,
|
|
||||||
isCollapsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { DiffFileSection } from "./types";
|
|
||||||
|
|
||||||
export interface VisibleDiffFileSections {
|
|
||||||
files: DiffFileSection[];
|
|
||||||
sticky: { file: DiffFileSection; y: number } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffInteractionWindowTop(scrollTop: number, viewportHeight: number): number {
|
|
||||||
"worklet";
|
|
||||||
const bucketHeight = Math.max(1, viewportHeight * 2);
|
|
||||||
return Math.floor(Math.max(0, scrollTop) / bucketHeight) * bucketHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffMaterializationWindow(
|
|
||||||
windowTop: number,
|
|
||||||
viewportHeight: number,
|
|
||||||
): { top: number; height: number } {
|
|
||||||
return {
|
|
||||||
top: Math.max(0, windowTop - viewportHeight * 2),
|
|
||||||
height: viewportHeight * 5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffHeaderViewportKey(input: {
|
|
||||||
files: readonly DiffFileSection[];
|
|
||||||
scrollTop: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
activePath: string | null;
|
|
||||||
}): string {
|
|
||||||
const window = resolveVisibleFileSections({ ...input, overscan: 0 });
|
|
||||||
const headers = window.files
|
|
||||||
.filter((file) => file !== window.sticky?.file)
|
|
||||||
.map((file) => `${file.fileIndex}:${file.top - input.scrollTop}`);
|
|
||||||
if (window.sticky) headers.push(`${window.sticky.file.fileIndex}:${window.sticky.y}`);
|
|
||||||
return `${input.activePath ?? ""}|${headers.join(",")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveVisibleFileSections(input: {
|
|
||||||
files: readonly DiffFileSection[];
|
|
||||||
scrollTop: number;
|
|
||||||
viewportHeight: number;
|
|
||||||
overscan: number;
|
|
||||||
}): VisibleDiffFileSections {
|
|
||||||
if (input.files.length === 0 || input.viewportHeight <= 0) {
|
|
||||||
return { files: [], sticky: null };
|
|
||||||
}
|
|
||||||
const visibleTop = Math.max(0, input.scrollTop - input.overscan);
|
|
||||||
const visibleBottom = input.scrollTop + input.viewportHeight + input.overscan;
|
|
||||||
const start = firstFileWhoseEndExceeds(input.files, visibleTop);
|
|
||||||
const files: DiffFileSection[] = [];
|
|
||||||
for (let index = start; index < input.files.length; index += 1) {
|
|
||||||
const file = input.files[index];
|
|
||||||
if (!file || file.top >= visibleBottom) break;
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
const stickyFile = input.files[firstFileWhoseEndExceeds(input.files, input.scrollTop)] ?? null;
|
|
||||||
const sticky = stickyFile
|
|
||||||
? {
|
|
||||||
file: stickyFile,
|
|
||||||
y: Math.min(0, stickyFile.bottom - input.scrollTop - stickyFile.headerHeight),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return { files, sticky };
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstFileWhoseEndExceeds(files: readonly DiffFileSection[], offset: number): number {
|
|
||||||
let low = 0;
|
|
||||||
let high = files.length;
|
|
||||||
while (low < high) {
|
|
||||||
const middle = (low + high) >>> 1;
|
|
||||||
const file = files[middle];
|
|
||||||
if (file && file.bottom <= offset) low = middle + 1;
|
|
||||||
else high = middle;
|
|
||||||
}
|
|
||||||
return low;
|
|
||||||
}
|
|
||||||
@@ -147,11 +147,6 @@ function buildModel(changedContent: string, overrides: Partial<BuildDiffDocument
|
|||||||
deletionBackground: "#100",
|
deletionBackground: "#100",
|
||||||
emptyBackground: "#111",
|
emptyBackground: "#111",
|
||||||
selection: "blue",
|
selection: "blue",
|
||||||
headerActiveSurface: "#222",
|
|
||||||
headerBorder: "#333",
|
|
||||||
statusSuccess: "green",
|
|
||||||
statusDanger: "red",
|
|
||||||
statusWarning: "orange",
|
|
||||||
syntax: {},
|
syntax: {},
|
||||||
},
|
},
|
||||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import { withUnistyles } from "react-native-unistyles";
|
|||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { createDiffPalette, retainDiffPalette } from "./palette";
|
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||||
import { DiffSurface } from "./surface";
|
import { DiffSurface } from "./surface";
|
||||||
import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
|
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||||
|
|
||||||
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
||||||
|
|
||||||
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
headerTypography: DiffHeaderTypography;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_PATHS: string[] = [];
|
const EMPTY_PATHS: string[] = [];
|
||||||
@@ -46,11 +45,6 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
|||||||
|
|
||||||
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
||||||
palette: createDiffPalette(theme),
|
palette: createDiffPalette(theme),
|
||||||
headerTypography: {
|
|
||||||
family: theme.fontFamily.ui,
|
|
||||||
size: theme.fontSize.base,
|
|
||||||
statSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function DiffDocument(props: DiffDocumentProps) {
|
export function DiffDocument(props: DiffDocumentProps) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user