Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 16d321899c fix(app): select current branch for new workspaces
Closes #541
2026-04-24 13:50:22 +07:00
623 changed files with 15842 additions and 52696 deletions
+9 -32
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -31,7 +31,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -47,7 +47,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -74,7 +74,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Fetch origin/main (worktree tests)
@@ -104,7 +104,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -122,12 +122,9 @@ jobs:
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.percent-escape.test.ts
src/utils/spawn.test.ts
src/utils/process-tree.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/terminal/worker-terminal-manager.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
@@ -138,26 +135,6 @@ jobs:
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
desktop-tests:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Run desktop tests
run: npm run test --workspace=@getpaseo/desktop
app-tests:
runs-on: ubuntu-latest
steps:
@@ -165,7 +142,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -187,7 +164,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -230,7 +207,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
@@ -249,7 +226,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Install dependencies
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
scope: "@boudra"
- name: Install dependencies
run: npm ci
run: npm install --workspace=@getpaseo/app --include-workspace-root
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+142 -225
View File
@@ -24,24 +24,6 @@ on:
- macos
- linux
- windows
checkout_ref:
description: "Optional branch/ref to checkout while using tag for release metadata."
required: false
default: ""
type: string
publish:
description: "Publish built artifacts to GitHub Releases."
required: false
default: "true"
type: choice
options:
- "true"
- "false"
rollout_hours:
description: "Linear rollout duration in hours. Use 0 for instant rollout."
required: false
default: "24"
type: string
concurrency:
group: desktop-release-${{ github.ref }}
@@ -49,14 +31,12 @@ concurrency:
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
DESKTOP_WORKSPACE: "@getpaseo/desktop"
DESKTOP_PACKAGE_PATH: "packages/desktop"
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }}
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -64,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ env.CHECKOUT_REF }}
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
@@ -111,7 +91,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.CHECKOUT_REF }}
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
@@ -147,6 +127,9 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
@@ -157,40 +140,123 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- 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: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync(manifestName, output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
publish-linux:
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
@@ -203,7 +269,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.CHECKOUT_REF }}
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
@@ -239,47 +305,25 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Install Linux smoke display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Build web app for desktop
run: npm run build:web --workspace=@getpaseo/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
build_args=(-- --publish never --linux --x64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: linux-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml
retention-days: 1
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: [create-release]
@@ -293,7 +337,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.CHECKOUT_REF }}
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
@@ -329,157 +373,30 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npx expo export --platform web
working-directory: packages/app
- name: Build desktop release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE: "1"
run: |
set -euo pipefail
build_args=(-- --publish never --win --x64 --arm64)
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
npm run build:desktop "${build_args[@]}"
- name: Upload desktop artifacts to release
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
release_dir="${DESKTOP_PACKAGE_PATH}/release"
files=()
while IFS= read -r -d '' f; do
files+=("$f")
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
if (( ${#files[@]} == 0 )); then
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload manifest artifact
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: windows-manifest
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml
retention-days: 1
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
concurrency:
group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
ref: ${{ env.CHECKOUT_REF }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download mac manifest artifacts
if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
path: release-manifests
- name: Download Linux manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success'
uses: actions/download-artifact@v4
with:
name: linux-manifest
path: release-manifests/linux-manifest
- name: Download Windows manifest artifact
if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success'
uses: actions/download-artifact@v4
with:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
"mac-manifest-arm64/${manifest_name}" \
"mac-manifest-x64/${manifest_name}" \
"$manifests_dir/${manifest_name}"
publish_mode="never"
publish_args=()
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
publish_mode="always"
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
fi
if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then
cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/"
fi
if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then
cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/"
fi
shopt -s nullglob
files=( "$manifests_dir"/*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifest artifacts were available to publish"
exit 1
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expectedHours) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
}
if (m.releaseDate !== expectedDate) {
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
-152
View File
@@ -1,152 +0,0 @@
name: Desktop Rollout
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to re-stamp (e.g. v0.1.42)."
required: true
type: string
rollout_hours:
description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately."
required: true
type: string
concurrency:
group: desktop-rollout-${{ inputs.tag }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ inputs.tag }}
jobs:
stamp:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
package.json
package-lock.json
scripts
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download release manifests
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir release-manifests
cd release-manifests
gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml"
shopt -s nullglob
files=( ./*.yml )
if (( ${#files[@]} == 0 )); then
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
exit 1
fi
echo "Downloaded ${#files[@]} manifest(s):"
printf ' %s\n' "${files[@]}"
- name: Capture before state
id: before
shell: bash
run: |
set -euo pipefail
cd release-manifests
summary=$(node -e '
const yaml = require("js-yaml");
const fs = require("fs");
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
console.log(` ${f}: rolloutHours=${m.rolloutHours ?? "<unset>"} releaseDate=${m.releaseDate ?? "<unset>"}`);
}
' ./*.yml)
echo "$summary"
{
echo "summary<<EOF"
echo "$summary"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Re-stamp rolloutHours
env:
NEW_HOURS: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml
- name: Validate rewritten manifests
env:
EXPECTED: ${{ inputs.rollout_hours }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
node -e '
const yaml = require("js-yaml");
const fs = require("fs");
const expected = Number(process.env.EXPECTED);
if (!Number.isFinite(expected) || expected < 0) {
throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`);
}
for (const f of process.argv.slice(1)) {
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
if (m.rolloutHours !== expected) {
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`);
}
if (typeof m.version !== "string" || m.version.length === 0) {
throw new Error(`${f}: missing or invalid version`);
}
}
' ./*.yml
- name: Upload to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
cd release-manifests
gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}"
- name: Write summary
env:
BEFORE: ${{ steps.before.outputs.summary }}
shell: bash
run: |
{
echo "## Rollout updated"
echo ""
echo "**Tag:** \`$RELEASE_TAG\`"
echo "**Channel:** \`$RELEASE_CHANNEL\`"
echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`"
echo ""
echo "### Before"
echo '```'
echo "$BEFORE"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "20"
cache: "npm"
- name: Fetch origin/main (worktree tests)
+3 -106
View File
@@ -1,119 +1,16 @@
# Changelog
## 0.1.66 - 2026-05-03
### Fixed
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
## 0.1.65 - 2026-05-03
### Added
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
- Pull and push your branch in one step from the git actions menu in the changes pane.
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
- **Windows:** Native ARM64 builds are now available.
- Bundled Paseo skills now refresh automatically on desktop app launch.
## 0.1.63-beta.1 - 2026-04-24
### Improved
- Codex streaming feels more responsive — message boundaries are preserved and output arrives sooner.
- Terminal sessions run in a dedicated worker process for better stability.
- New worktree branch names are derived from your prompt and attachments instead of a generic placeholder.
- Review comment UI is cleaner and easier to scan.
- The daemon's `/api/status` endpoint is now protected by password auth when one is configured.
### Fixed
- **Apple Silicon Mac:** The desktop update pipeline now publishes manifests atomically, closing a race that could install the Intel build on Apple Silicon Macs and cause 100%+ renderer CPU usage. Affected users will self-heal — electron-updater's Rosetta detection migrates back to arm64 on the next update poll. ([#555](https://github.com/getpaseo/paseo/issues/555))
- **Linux:** `.deb` and `.rpm` packages now show as `Paseo` in the dock and process list instead of `Paseo.bin`. `--no-sandbox` is now scoped to AppImage only, matching VS Code's sandbox handling. ([#602](https://github.com/getpaseo/paseo/issues/602))
- **Windows:** Git diff commands no longer break on paths with special characters. ([#629](https://github.com/getpaseo/paseo/pull/629))
- Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628))
- Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz))
- Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue))
- Agent timelines refresh with smaller catch-up requests when you reopen an agent.
- Terminal snapshots flush reliably before clients reconnect.
- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current.
- Voice dictation keeps recording when the agent tab loses focus.
- OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift))
- Codex plan approval panels no longer duplicate.
- Imported agents display the correct title immediately.
- OpenCode surfaces invalid mode/model errors instead of hanging.
- Archived worktrees stay hidden without flashing back into the list. ([#640](https://github.com/getpaseo/paseo/pull/640))
- Web dropdown menus no longer resize unexpectedly.
- The visible changes pane keeps in sync with the working tree diff.
- Tool detail rows on the timeline are selectable again.
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
- Isolated bottom sheet modals close and re-open without getting stuck.
## 0.1.64 - 2026-04-28
### Added
- OpenCode now has a Full Access mode that auto-approves tool calls. ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06))
- OpenCode supports executable slash commands. ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06))
### Improved
- `@`-mention stays responsive on very large projects. ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz))
### Fixed
- Workspaces still load when `paseo.json` has a parse error.
## 0.1.63 - 2026-04-28
### Added
- Project settings page with a built-in `paseo.json` editor.
- Cold start restores your last open workspace.
- Tool call badges have a button to open the referenced file directly.
- Open the current branch on GitHub from a workspace's open menu. ([#583](https://github.com/getpaseo/paseo/pull/583) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
- Enable or disable providers from Settings without editing config files.
- Paseo prompts you to configure a worktree setup script when one is missing.
- Choose whether the daemon shuts down when you close the desktop app.
### Improved
- Provider settings and model selection have been redesigned.
- Voice mode transcription endpoint is configurable for OpenAI-compatible providers. ([#570](https://github.com/getpaseo/paseo/pull/570) by [@yuruiz](https://github.com/yuruiz))
- Adding a project no longer waits for GitHub PR status to load.
- Startup splash screen is cleaner — just the logo with a subtle shimmer.
- `paseo.json` setup and teardown accept a single command string, not just an array.
- Archiving a worktree is instant instead of waiting for the backend to confirm.
- Terminal sessions recover cleanly after rendering hiccups.
- Agent timelines and git diff lists no longer jump around while loading or streaming.
### Fixed
- `paseo loop run` and `paseo run` now respect the `--provider` and `--model` flags. ([#594](https://github.com/getpaseo/paseo/pull/594) by [@VincenzoRocchi](https://github.com/VincenzoRocchi))
- Pi provider shows up when only DeepSeek or other non-OpenAI/Anthropic/OpenRouter API keys are set.
- Custom models from `additionalModels` and `profileModels` are honored when picking a default for new agents.
- File preview line numbers stay on one line past line 99.
- Cmd+Q on macOS quits the desktop app instead of leaving it running in the background.
- Terminal sessions recover cleanly after rendering hiccups, including the initial resize for nvim.
- Terminal protocol query responses no longer leak into the browser.
- Assistant link color matches the theme again.
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
- Claude's Grep results show up in the search detail body.
- Reopening a worktree lands under the right project.
- Agents from disabled or unavailable providers stay visible in history.
- New CLI agents now require a provider instead of failing silently.
- Git diff headers no longer truncate.
- Provider diagnostic modal scrolls on short screens.
- Provider diagnostics show the real error and underlying child-process output instead of a generic message.
- Archived workspaces no longer interfere with working-directory resolution.
- Triple-click on a message no longer extends the selection into adjacent bubbles.
- The packaged desktop app preserves your zsh prompt.
## 0.1.62 - 2026-04-23
@@ -245,7 +142,7 @@
### Added
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
- Choose provider and model when creating scheduled agents.
- Max reasoning effort option for Opus 4.6 models.
+9 -20
View File
@@ -19,14 +19,14 @@ This is an npm workspace monorepo:
| Doc | What's in it |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/design.md](docs/design.md) | How to design features before implementation |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start
@@ -41,7 +41,7 @@ npm run format # Auto-format with Biome
npm run format:check # Check formatting without writing
```
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
## Critical rules
@@ -55,9 +55,6 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
- `npm run lint -- packages/app/src/components/message.tsx`
@@ -103,14 +100,6 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
use-audio-recorder.native.ts ← uses expo-audio
```
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
```
components/
browser-pane.electron.tsx ← Electron <webview> implementation
browser-pane.web.tsx ← plain web fallback
browser-pane.tsx ← native fallback
```
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
+6 -6
View File
@@ -27,10 +27,10 @@ If you want to propose a direction change, start a conversation.
Please read these first:
- [README.md](README.md)
- [docs/architecture.md](docs/architecture.md)
- [docs/development.md](docs/development.md)
- [docs/coding-standards.md](docs/coding-standards.md)
- [docs/testing.md](docs/testing.md)
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
- [docs/TESTING.md](docs/TESTING.md)
- [CLAUDE.md](CLAUDE.md)
## What is most helpful
@@ -105,7 +105,7 @@ npm run dev:website
npm run cli -- ls -a -g
```
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
## Multi-platform testing
@@ -132,7 +132,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n
Paseo has explicit standards. Follow them.
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
## PR checklist
-10
View File
@@ -144,16 +144,6 @@ npm run build:daemon
npm run typecheck
```
<p align="center">
<a href="https://star-history.com/#getpaseo/paseo&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
</picture>
</a>
</p>
## License
AGPL-3.0
View File
@@ -137,21 +137,6 @@ Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
```ts
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
## Agent lifecycle
```
@@ -0,0 +1,158 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
+2 -2
View File
@@ -17,7 +17,7 @@ Before designing anything new, understand what exists:
- Where does similar functionality live?
- What patterns does the codebase already use?
- What layers exist? (See [architecture.md](./architecture.md))
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
- What types and data shapes are already defined?
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
@@ -38,7 +38,7 @@ If you can't define verification, you don't understand the feature well enough y
- What types are needed?
- Use discriminated unions — make impossible states impossible
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
### Layers
@@ -62,18 +62,6 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
```json
{
"worktree": {
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
"teardown": "npm run db:drop || true"
}
}
```
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
@@ -261,4 +261,4 @@ xcrun simctl ui booted appearance dark # set dark
xcrun simctl ui booted appearance light # set light
```
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [development.md](development.md)).
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
View File
+1 -101
View File
@@ -59,94 +59,6 @@ Use the beta path when you need to:
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
## Staged rollout (stable channel)
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
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
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
### Instant-admit release (rollout_hours=0 from publish)
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
```bash
# 1. Cut and publish (default 24h ramp from tag push).
npm run release:patch
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
gh workflow run desktop-rollout.yml \
-f tag=v0.1.64 \
-f rollout_hours=0
```
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
### Adjusting an already-published release
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
**Hotfix (instant admit) on an already-shipped release:**
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=0
```
`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients).
**Slow a rollout down** (e.g. extend total duration to 72h since the original release):
```bash
gh workflow run desktop-rollout.yml \
-f tag=v0.1.42 \
-f rollout_hours=72
```
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
### Custom ramp on a manually-dispatched build
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
```bash
gh workflow run desktop-release.yml \
-f tag=v0.1.43 \
-f rollout_hours=6
```
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
### Releasing during an active rollout
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
### Limitations
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
## Website behavior
- The website download page points to GitHub's latest published **stable** release.
@@ -228,8 +140,6 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
@@ -259,17 +169,7 @@ Rules:
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
Use this command to get the GitHub logins for each PR:
```bash
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'
```
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
## Changelog ordering
View File
-68
View File
@@ -4,74 +4,6 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
```tsx
const styles = StyleSheet.create((theme) => ({
container: {
backgroundColor: theme.colors.surface0,
padding: theme.spacing[4],
},
}));
<View style={styles.container} />;
```
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
### 2. Hard-coded constants for genuinely static values
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
### 3. `withUnistyles(Component)` for third-party props
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
```tsx
const ThemedBlur = withUnistyles(BlurView);
<ThemedBlur tint={theme.colors.surface0} />;
```
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, tabs row, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
-247
View File
@@ -1,247 +0,0 @@
# Design system
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
---
## 1. Character
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
---
## 2. Component reuse
A semantic element used in three or more places is a primitive. One of a kind is a screen.
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
Before adding a new component, read `components/ui/`. The primitive usually exists.
---
## 3. Hierarchy
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
Weight has three tiers, applied by role:
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
---
## 4. Buttons
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
---
## 5. Borders
Borders group, separate, or rarely emphasize.
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
---
## 6. Pickers
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
---
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
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.
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.
The whitespace is the design.
---
## 8. Responsiveness
Compact-first. The small case is designed; the large case adds chrome around it.
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
---
## 9. Copy and voice
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
Terminology:
- Workspace, never "checkout".
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
- Project, not "repo" or "repository".
- Provider, not "model provider".
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
---
## 10. States
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
---
## 11. List rows
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
---
## 12. Status pills and badges
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
---
## 13. Forbidden
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
---
## 14. Canonical surfaces by pattern
| Pattern | Reference |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
-45
View File
@@ -1,45 +0,0 @@
# Paseo Glossary
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
## Open question — per-host project entry (TBD)
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
## Rename before landing (in-progress `Checkout*` references)
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
## Inconsistencies (documented, not papered over)
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
+68
View File
@@ -0,0 +1,68 @@
# Plan Approval Normalization
## Goal
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
## Compatibility Constraints
- Older clients must remain compatible with newer daemons.
- All new wire fields must be optional.
- Existing plan permissions without action metadata must still render and work.
- Existing question permissions must keep their current behavior.
## Design
### Shared abstraction
Add optional permission action definitions to the shared permission request/response types.
- Permission requests may include `actions`.
- Permission responses may include `selectedActionId`.
- `kind: "plan"` remains the normalized concept for plan approval.
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
### Claude
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
- Always expose `Reject`.
- Always expose `Implement`.
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
- Resolve the selected action entirely inside `respondToPermission()`.
### Codex
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
- Emit a plan permission with `Reject` and `Implement` actions.
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
- On `Reject`, resolve without starting a follow-up turn.
- Keep the implementation prompt and state transitions inside the Codex provider.
### Manager and state sync
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
- Refresh current mode
- Refresh pending permissions
- Refresh runtime info
- Refresh features
- Persist refreshed state
### UI
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
- If `actions` are absent, fall back to legacy buttons.
- Plan cards should use `Implement` as the default primary label.
- Do not add provider-specific rendering branches.
## Verification
1. Shared schema/type tests for optional `actions` and `selectedActionId`
2. App tests for generic plan-action rendering
3. Claude tests for third action when resuming from a more permissive mode
4. Codex tests for synthetic plan approval and automatic implementation follow-up
5. Manager tests for post-permission state refresh
6. `npm run typecheck`
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-6jn98U9nIh8pK4EseV6WJOIcr7JEkUm9C2NYWDUUMCQ=";
npmDepsHash = "sha256-0Izp63gVeBO9Efo4Uf8/EB3TkQ9FXJ4Td2Fe5UHm9eE=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+503 -597
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.66",
"version": "0.1.63-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -63,7 +63,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -94,9 +94,7 @@
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
@@ -5,7 +5,6 @@ on:
tags:
- "v*"
- "!v*-rc.*"
- "!v*-beta.*"
workflow_dispatch: {}
jobs:
@@ -46,6 +45,8 @@ jobs:
steps:
- uses: eas/checkout
- name: Install fastlane
working_directory: ./packages/app
run: bundle install
- name: Submit for review
working_directory: ./packages/app
run: bundle exec fastlane ios submit_review
-3
View File
@@ -37,9 +37,6 @@ yarn-error.*
# typescript
*.tsbuildinfo
# vitest browser failure screenshots
.vitest-screenshots/
app-example
# generated native folders
@@ -1,61 +0,0 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
test.describe("Codex plan approval", () => {
test("shows a single actionable plan panel and removes it after implementation starts", async ({
page,
}) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("codex-plan-approval-");
const client = await connectTerminalClient();
try {
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Codex plan approval e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "Emit synthetic plan approval.",
});
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await waitForPermissionPrompt(page, 120_000);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(1);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
await allowPermission(page);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});
-1
View File
@@ -585,7 +585,6 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
PASEO_NODE_ENV: "development",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
+2 -1
View File
@@ -140,6 +140,7 @@ class StartupAssertions {
async expectsReconnectWelcome(): Promise<this> {
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
@@ -163,7 +164,7 @@ class StartupAssertions {
}
async expectsDesktopDaemonStartup(): Promise<this> {
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({
timeout: 15_000,
});
return this;
@@ -24,10 +24,6 @@ export interface TerminalPerfDaemonClient {
cwd: string;
title?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal(
-122
View File
@@ -1,122 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
const updatedSetup = ["npm install", "npm run build"];
interface ProjectsSettingsProject {
name: string;
path: string;
}
interface ProjectsSettingsFixtures {
editableProject: ProjectsSettingsProject;
}
const initialPaseoConfig = {
worktree: {
setup: ["echo initial setup"],
teardown: "echo cleanup",
customWorktreeField: "preserved",
},
scripts: {
dev: {
command: "npm run dev",
type: "server",
port: 3000,
customScriptField: "preserved",
},
},
customTopLevelField: "preserved",
};
const test = base.extend<ProjectsSettingsFixtures>({
editableProject: async ({ page: _page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const repo = await createTempGitRepo("projects-settings-", {
paseoConfig: initialPaseoConfig,
});
const openedProject = await openProjectViaDaemon(client, repo.path);
await provide({
name: openedProject.projectDisplayName,
path: repo.path,
});
await client.close();
await repo.cleanup();
},
});
async function openProjects(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
}
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
timeout: 30_000,
});
}
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
await page
.getByRole("textbox", { name: "Worktree setup commands" })
.fill(setupCommands.join("\n"));
}
async function saveProjectConfig(page: Page): Promise<void> {
await page.getByRole("button", { name: "Save project config" }).click();
}
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
await expect
.poll(
async () => {
const contents = await readProjectConfigFile(project);
return JSON.parse(contents) as unknown;
},
{
timeout: 30_000,
},
)
.toMatchObject({
worktree: {
setup: updatedSetup,
teardown: initialPaseoConfig.worktree.teardown,
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
},
scripts: {
dev: {
command: initialPaseoConfig.scripts.dev.command,
type: initialPaseoConfig.scripts.dev.type,
port: initialPaseoConfig.scripts.dev.port,
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
},
},
customTopLevelField: initialPaseoConfig.customTopLevelField,
});
const savedConfig = await readProjectConfigFile(project);
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
}
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
return readFile(path.join(project.path, "paseo.json"), "utf8");
}
test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await editWorktreeSetup(page, updatedSetup);
await saveProjectConfig(page);
await expectProjectConfigSaved(editableProject);
});
});
+8 -10
View File
@@ -53,7 +53,8 @@ test.describe("Settings host page", () => {
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
@@ -129,10 +130,13 @@ test.describe("Settings host page", () => {
const serverId = getSeededServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
// from get_desktop_settings) bypasses the desktop bootstrap flow so only
// the sidebar's status query runs against the seeded test daemon.
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
// the desktop bootstrap flow so only the sidebar's status query runs.
await page.addInitScript((localServerId) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
);
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
@@ -149,12 +153,6 @@ test.describe("Settings host page", () => {
error: null,
};
}
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
getPendingOpenProject: async () => null,
@@ -59,35 +59,6 @@ test.describe("Settings sidebar navigation", () => {
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
await page.getByTestId("direct-host-advanced-toggle").click();
await page
.getByTestId("direct-host-uri-input")
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -1,7 +1,6 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTabTestIds } from "./helpers/launcher";
import {
installTerminalRenderProbe,
readTerminalRenderProbe,
@@ -11,53 +10,6 @@ import {
} from "./helpers/terminal-probes";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalLayoutMetrics {
visibleSurfaceCount: number;
surfaceHeight: number;
rowCount: number;
rows: number | null;
cols: number | null;
renderedRowsHeight: number;
tabIds: string[];
}
async function readTerminalLayoutMetrics(page: Page): Promise<TerminalLayoutMetrics> {
const tabIds = await getTabTestIds(page);
return page.evaluate((currentTabIds) => {
const visibleSurfaces = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]'),
).filter((candidate) => {
const rect = candidate.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
const surface = visibleSurfaces[0] ?? null;
const surfaceRect = surface?.getBoundingClientRect() ?? null;
// xterm.js exposes `.xterm-screen` regardless of renderer (DOM, canvas, WebGL),
// so use it as the canonical "rendered surface" rect rather than `.xterm-rows`
// which is only populated by the DOM renderer.
const xtermScreen = surface?.querySelector<HTMLElement>(".xterm-screen") ?? null;
const xtermScreenRect = xtermScreen?.getBoundingClientRect() ?? null;
const term = (
window as Window & {
__paseoTerminal?: {
rows?: number;
cols?: number;
};
}
).__paseoTerminal;
return {
visibleSurfaceCount: visibleSurfaces.length,
surfaceHeight: surfaceRect?.height ?? 0,
rowCount: typeof term?.rows === "number" ? term.rows : 0,
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
renderedRowsHeight: xtermScreenRect?.height ?? 0,
tabIds: currentTabIds,
};
}, tabIds);
}
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
let lastBufferText = "";
let lastProbe = await readTerminalRenderProbe(page);
@@ -101,8 +53,6 @@ async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout:
}
test.describe("Terminal alternate-screen transitions", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
@@ -188,62 +138,4 @@ test.describe("Terminal alternate-screen transitions", () => {
).toEqual([]);
});
});
test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1280, height: 900 });
await installTerminalRenderProbe(page);
await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => {
await harness.setupPrompt(page);
const terminalSurface = harness.terminalSurface(page).first();
await expect(terminalSurface).toBeVisible({ timeout: 15_000 });
await terminalSurface.click();
const beforeVim = await readTerminalLayoutMetrics(page);
await startTerminalFrameSampling(page, 2_000);
await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 });
await page.waitForTimeout(1_500);
const probe = await readTerminalRenderProbe(page);
const samples: TerminalLayoutMetrics[] = [];
for (let index = 0; index < 12; index += 1) {
samples.push(await readTerminalLayoutMetrics(page));
await page.waitForTimeout(50);
}
await testInfo.attach("reused-terminal-layout-metrics", {
body: JSON.stringify(
{
beforeVim,
probe: summarizeTerminalRenderProbe(probe),
samples,
},
null,
2,
),
contentType: "application/json",
});
const firstSample = samples[0];
const finalSample = samples.at(-1);
expect(firstSample, "expected an initial layout sample").toBeTruthy();
expect(finalSample, "expected a final layout sample").toBeTruthy();
expect(
finalSample?.visibleSurfaceCount ?? 0,
"opening vim should leave exactly one visible terminal surface",
).toBe(1);
expect(
finalSample?.renderedRowsHeight ?? 0,
"vim should render rows that fill most of the terminal surface",
).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75);
expect(
finalSample?.rowCount ?? 0,
"vim should leave a substantial number of rendered rows",
).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75)));
});
});
});
@@ -68,22 +68,6 @@ terminalPerfDescribe("Terminal keystroke stress", () => {
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
const appObserveNodeBurstReport = await measureAppObservingNodeBurstEcho({
page,
harness,
terminalName: "app-observe-node-burst",
});
await attachJson(testInfo, "app-observe-node-burst", appObserveNodeBurstReport);
console.log(
"[terminal-stress-app-observe-node-burst]",
JSON.stringify(appObserveNodeBurstReport),
);
expect(appObserveNodeBurstReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(
INPUT_TEXT.length,
);
expect(appObserveNodeBurstReport.xtermWriteCount).toBeGreaterThan(0);
const appSmallChunksReport = await measureAppBurstEcho({
page,
harness,
@@ -188,34 +172,6 @@ async function measureAppBurstEcho(input: {
}
}
async function measureAppObservingNodeBurstEcho(input: {
page: Page;
harness: TerminalE2EHarness;
terminalName: string;
}) {
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
try {
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
await input.harness.setupPrompt(input.page);
await resetTerminalKeystrokeStressProbe(input.page);
for (const char of INPUT_TEXT) {
input.harness.client.sendTerminalInput(appTerminal.id, {
type: "input",
data: char,
});
}
await waitForAppStressEcho(input.page, INPUT_TEXT);
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
} finally {
await input.harness.killTerminal(appTerminal.id);
}
}
async function emitRapidAgentStreamUpdates(
harness: TerminalE2EHarness,
input: { agentId: string; count: number },
@@ -1,6 +1,5 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import type { WebSocketRoute } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
@@ -28,203 +27,9 @@ import {
waitForSidebarHydration,
} from "./helpers/workspace-ui";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function expectNoLoadingWorkspacePane(
page: Page,
input: { label: string; durationMs?: number },
): Promise<void> {
const durationMs = input.durationMs ?? 2000;
const startedAt = Date.now();
const samples: string[] = [];
while (Date.now() - startedAt < durationMs) {
const url = page.url();
const text = await page
.locator("body")
.innerText({ timeout: 250 })
.catch((error) => `[body unavailable: ${error instanceof Error ? error.message : error}]`);
samples.push(`${Date.now() - startedAt}ms ${url}\n${text.slice(0, 1000)}`);
if (LOADING_WORKSPACE_TEXT_PATTERN.test(text)) {
throw new Error(
`${input.label}: loading workspace pane appeared during reconnect window.\n\n${samples.join(
"\n\n---\n\n",
)}`,
);
}
await page.waitForTimeout(100);
}
}
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
let acceptingConnections = true;
const activeSockets = new Set<WebSocketRoute>();
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
return;
}
activeSockets.add(ws);
const server = ws.connectToServer();
ws.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
server.send(message);
} catch {
activeSockets.delete(ws);
}
});
server.onMessage((message) => {
if (!acceptingConnections) {
return;
}
try {
ws.send(message);
} catch {
activeSockets.delete(ws);
}
});
});
return {
async drop(): Promise<void> {
acceptingConnections = false;
const sockets = Array.from(activeSockets);
activeSockets.clear();
await Promise.all(
sockets.map((ws) =>
ws
.close({ code: 1008, reason: "Dropped by workspace reconnect regression test." })
.catch(() => undefined),
),
);
},
restore(): void {
acceptingConnections = true;
},
};
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
const workspaceClient = await connectNewWorkspaceDaemonClient();
const workspaceIds = new Set<string>();
const repo = await createTempGitRepo("workspace-reconnect-");
try {
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
workspaceIds.add(workspace.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: workspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
timeout: 30_000,
});
await daemonGate.drop();
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
label: "host reconnect",
});
daemonGate.restore();
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
timeout: 30_000,
});
await monitorReconnect;
await expectWorkspaceHeader(page, {
title: workspace.workspaceName,
subtitle: workspace.projectDisplayName,
});
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
} finally {
daemonGate.restore();
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await workspaceClient.close().catch(() => undefined);
await repo.cleanup();
}
});
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
});
await page.goto(
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expect(
page.getByText(/Connecting to localhost|localhost is offline|Cannot reach localhost/i),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("menu-button")).toBeVisible();
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
await settingsButton.click();
await expect(page).toHaveURL(/\/settings\/general$/);
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
@@ -1,6 +1,7 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { waitForTerminalContent } from "./helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
@@ -278,7 +279,7 @@ test.describe("Workspace setup streaming", () => {
await page.getByTestId("workspace-scripts-button").click();
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("workspace-scripts-start-web").click();
await page.getByTestId("workspace-scripts-menu-backdrop").click();
await page.keyboard.press("Escape");
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
@@ -299,7 +300,9 @@ test.describe("Workspace setup streaming", () => {
await terminalSurface.click();
await expect(terminalTab).toContainText("web");
// Verify the terminal output contains "listening on" via xterm buffer API.
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
} finally {
await client.close();
await repo.cleanup();
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.66",
"version": "0.1.63-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -31,8 +31,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "*",
"@getpaseo/highlight": "*",
"@getpaseo/expo-two-way-audio": "0.1.63-beta.1",
"@getpaseo/highlight": "0.1.63-beta.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+152 -138
View File
@@ -19,18 +19,16 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -50,19 +48,17 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -74,7 +70,6 @@ import {
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
@@ -104,17 +99,15 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
export interface HostRuntimeBootstrapState {
splashError: string | null;
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
storeReady: boolean;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
splashError: null,
phase: "starting-daemon",
error: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
storeReady: false,
});
function PushNotificationRouter() {
@@ -186,18 +179,19 @@ function PushNotificationRouter() {
});
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -272,91 +266,115 @@ function HostSessionManager() {
);
}
export function useEarliestOnlineHostServerId(): string | null {
const store = getHostRuntimeStore();
const subscribe = useCallback(
(listener: () => void) => {
const unsubscribeAll = store.subscribeAll(listener);
const unsubscribeHostList = store.subscribeHostList(listener);
return () => {
unsubscribeAll();
unsubscribeHostList();
};
},
[store],
);
return useSyncExternalStore(
subscribe,
() => store.getEarliestOnlineHostServerId(),
() => store.getEarliestOnlineHostServerId(),
);
}
function useDaemonStartLastError(): string | null {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.getLastError(),
() => service.getLastError(),
);
}
function useDaemonStartIsRunning(): boolean {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.isRunning(),
() => service.isRunning(),
);
}
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldUseDesktopDaemon(),
});
}, []);
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
useEffect(() => {
if (
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
hasGivenUpWaitingForHost
) {
return;
}
const handle = setTimeout(() => {
setHasGivenUpWaitingForHost(true);
}, STARTUP_GIVE_UP_TIMEOUT_MS);
return () => {
clearTimeout(handle);
};
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
const isCurrentlyStoreReady =
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
useEffect(() => {
let cancelled = false;
let cancelAnyOnline: (() => void) | null = null;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
let raceSettled = false;
const anyOnline = store.waitForAnyConnectionOnline();
cancelAnyOnline = anyOnline.cancel;
const bootstrapPromise = (async (): Promise<
{ type: "online" } | { type: "error"; error: string }
> => {
try {
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
return { type: "error", error: bootstrapResult.error };
}
if (!cancelled && !raceSettled) {
setPhase("connecting");
}
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
return { type: "online" };
} catch (err) {
return {
type: "error",
error: err instanceof Error ? err.message : String(err),
};
}
})();
const result = await Promise.race([
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
bootstrapPromise,
]);
raceSettled = true;
anyOnline.cancel();
if (!cancelled) {
if (result.type === "online") {
setPhase("online");
setError(null);
} else {
setPhase("error");
setError(result.error);
}
}
} else {
setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
return () => {
cancelled = true;
cancelAnyOnline?.();
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
return (
@@ -367,7 +385,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).storeReady;
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
@@ -394,6 +412,7 @@ function AppContainer({
selectedAgentId,
chromeEnabled: chromeEnabledOverride,
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
@@ -450,8 +469,13 @@ function AppContainer({
useActiveWorktreeNewAction();
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
const content = (
<View style={layoutStyles.surfaceFill}>
<View style={containerStyle}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
@@ -460,9 +484,8 @@ function AppContainer({
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<RosettaCalloutSource />
<UpdateCalloutSource />
<WorktreeSetupCalloutSource />
<DaemonVersionMismatchCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -471,7 +494,6 @@ function AppContainer({
/>
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
</View>
);
@@ -589,6 +611,9 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const { theme } = useUnistyles();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
// Apply theme setting on mount and when it changes
useEffect(() => {
@@ -601,9 +626,21 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}
}, [settingsLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || isNative) {
return;
}
void updateDesktopWindowControls({
backgroundColor: theme.colors.surface0,
foregroundColor: theme.colors.foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
return (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
<FaviconStatusSync />
@@ -612,24 +649,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
);
}
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
useEffect(() => {
if (!enabled || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
return null;
}
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
@@ -826,8 +845,6 @@ function RootStack() {
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="settings/projects/index" />
<Stack.Screen name="settings/projects/[projectKey]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
{/*
@@ -909,23 +926,20 @@ function RootProviders({ children }: { children: ReactNode }) {
}
export default function RootLayout() {
const { theme } = useUnistyles();
const gestureRootStyle = useMemo(
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</View>
<GestureHandlerRootView style={gestureRootStyle}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</GestureHandlerRootView>
);
}
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
}));
@@ -1,190 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupRedirectRoute,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
function createFakeStore() {
return { boot: vi.fn() };
}
function createFakeDaemonStartService() {
return {
start: vi.fn(async () => ({ ok: true as const })),
};
}
describe("startHostRuntimeBootstrap", () => {
it("fires boot and daemon-start without awaiting the daemon-start promise", () => {
const events: string[] = [];
const store = {
boot: vi.fn(() => {
events.push("boot");
}),
};
const daemonStartService = {
start: vi.fn(async () => {
events.push("daemon-start");
return { ok: true as const };
}),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
expect(events).toEqual(["boot", "daemon-start"]);
});
it("skips daemon-start when shouldStartDaemon is false", () => {
const store = createFakeStore();
const daemonStartService = createFakeDaemonStartService();
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: false,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).not.toHaveBeenCalled();
});
it("does not await the daemon-start promise", () => {
const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined;
const daemonStartService = {
start: vi.fn(
() =>
new Promise<{ ok: true }>((resolve) => {
resolveStart = resolve;
}),
),
};
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: true,
});
expect(store.boot).toHaveBeenCalledTimes(1);
expect(daemonStartService.start).toHaveBeenCalledTimes(1);
resolveStart?.({ ok: true });
});
});
describe("resolveStartupRedirectRoute", () => {
const baseInput = {
pathname: "/",
anyOnlineHostServerId: null,
workspaceSelection: null,
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
};
it("returns null when the pathname is not the index route", () => {
expect(
resolveStartupRedirectRoute({
...baseInput,
pathname: "/h/server-1",
anyOnlineHostServerId: "server-1",
}),
).toBeNull();
});
it("waits while the persisted workspace selection has not finished loading", () => {
expect(
resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-1",
isWorkspaceSelectionLoaded: false,
}),
).toBeNull();
});
it("waits while no host is online and the give-up timer has not fired", () => {
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
});
describe("scenario: saved-host-online", () => {
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-1",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-1/workspace/workspace-a");
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-2");
});
it("redirects to the host root when no persisted workspace exists", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
});
expect(route).toBe("/h/server-2");
});
});
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
it("redirects to the host that came online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "srv_desktop",
});
expect(route).toBe("/h/srv_desktop");
});
});
describe("scenario: both-succeed", () => {
it("redirects to the host that the host runtime selects as earliest online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-saved/workspace/workspace-a");
});
});
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
it("redirects to the welcome route", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
hasGivenUpWaitingForHost: true,
});
expect(route).toBe(WELCOME_ROUTE);
});
it("still redirects to the host when one comes online before the timer expires", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
hasGivenUpWaitingForHost: true,
});
expect(route).toBe("/h/server-saved");
});
});
});
@@ -1,63 +0,0 @@
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
import type { Href } from "expo-router";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
boot: () => void;
}
export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>;
}
export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: boolean;
}
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot();
if (input.shouldStartDaemon) {
void input.daemonStartService.start();
}
}
export const WELCOME_ROUTE: Href = "/welcome";
export interface ResolveStartupRedirectInput {
pathname: string;
anyOnlineHostServerId: string | null;
workspaceSelection: ActiveWorkspaceSelection | null;
isWorkspaceSelectionLoaded: boolean;
hasGivenUpWaitingForHost: boolean;
}
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
if (input.pathname !== "/" && input.pathname !== "") {
return null;
}
if (!input.isWorkspaceSelectionLoaded) {
return null;
}
if (input.anyOnlineHostServerId) {
if (
input.workspaceSelection &&
input.workspaceSelection.serverId === input.anyOnlineHostServerId
) {
return buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
);
}
return buildHostRootRoute(input.anyOnlineHostServerId);
}
if (input.hasGivenUpWaitingForHost) {
return WELCOME_ROUTE;
}
return null;
}
-148
View File
@@ -1,148 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
redirectMock: vi.fn(),
state: hoistedState,
};
});
vi.mock("expo-router", () => ({
Redirect: ({ href }: { href: string }) => {
redirectMock(href);
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
},
usePathname: () => state.pathname,
}));
vi.mock("@/app/_layout", () => ({
useHostRuntimeBootstrapState: () => state.bootstrapState,
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
shouldUseDesktopDaemon: () => false,
}));
vi.mock("@/screens/startup-splash-screen", () => ({
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
}));
describe("Index route startup navigation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.resetModules();
state.pathname = "/";
state.bootstrapState = {
splashError: null,
retry: vi.fn(),
hasGivenUpWaitingForHost: false,
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
redirectMock.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
async function renderIndex() {
const { default: Index } = await import("./index");
await act(async () => {
root.render(<Index />);
});
}
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("navigates to the host root when no persisted workspace exists", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = null;
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
});
it("falls back to welcome when the give-up timer fires with no host online", async () => {
state.bootstrapState = {
...state.bootstrapState,
hasGivenUpWaitingForHost: true,
};
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/welcome");
});
});
+46 -22
View File
@@ -1,35 +1,59 @@
import React from "react";
import { Redirect, usePathname } from "expo-router";
import { useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
import {
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
} from "@/stores/navigation-active-workspace-store";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => null,
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection: isWorkspaceSelectionLoaded
? getLastNavigationWorkspaceRouteSelection()
: null,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
useEffect(() => {
if (!storeReady) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
}
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
@@ -1,12 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;
}
@@ -1,7 +0,0 @@
import SettingsScreen from "@/screens/settings-screen";
const PROJECTS_VIEW = { kind: "projects" as const };
export default function SettingsProjectsIndexRoute() {
return <SettingsScreen view={PROJECTS_VIEW} />;
}
@@ -1,337 +0,0 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
}
interface RemoveWorkspaceAttachmentInput {
selectedAttachments: readonly ComposerAttachment[];
index: number;
}
interface OpenWorkspaceAttachmentInput {
attachment: ComposerAttachment;
}
interface CompleteSubmitInput {
result: "noop" | "queued" | "submitted" | "failed";
outgoingAttachments: readonly ComposerAttachment[];
}
interface ComposerWorkspaceAttachmentBinding {
selectedAttachments: ComposerAttachment[];
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
completeSubmit: (input: CompleteSubmitInput) => void;
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
key={`workspace:${getAttachmentKey(args.attachment)}`}
{...args}
attachment={args.attachment}
/>
);
}
function useWorkspaceAttachmentBinding({
normalAttachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
const clearReviewDraft = useClearReviewDraft();
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
const workspaceAttachmentKeys = useMemo(
() => workspaceAttachments.map(getAttachmentKey),
[workspaceAttachments],
);
const activeWorkspaceAttachments = useMemo(
() =>
workspaceAttachments.filter(
(attachment, index) => !suppressedKeys.includes(workspaceAttachmentKeys[index] ?? ""),
),
[suppressedKeys, workspaceAttachmentKeys, workspaceAttachments],
);
const selectedAttachments = useMemo<ComposerAttachment[]>(
() =>
activeWorkspaceAttachments.length > 0
? [...normalAttachments, ...activeWorkspaceAttachments]
: normalAttachments,
[activeWorkspaceAttachments, normalAttachments],
);
useEffect(() => {
setSuppressedKeys((current) => {
const next = current.filter((suppressedKey) =>
workspaceAttachmentKeys.includes(suppressedKey),
);
return next.length === current.length ? current : next;
});
}, [workspaceAttachmentKeys]);
const buildOutgoingAttachments = useCallback(
(attachments: UserComposerAttachment[]): ComposerAttachment[] =>
activeWorkspaceAttachments.length > 0
? [...attachments, ...activeWorkspaceAttachments]
: attachments,
[activeWorkspaceAttachments],
);
const suppressWorkspaceAttachment = useCallback((attachment: WorkspaceComposerAttachment) => {
const key = getAttachmentKey(attachment);
setSuppressedKeys((current) => (current.includes(key) ? current : [...current, key]));
}, []);
const clearSentAttachments = useCallback(
(attachments: readonly ComposerAttachment[]) => {
for (const attachment of attachments) {
if (attachment.kind === "review") {
clearReviewDraft({ key: attachment.reviewDraftKey });
}
}
},
[clearReviewDraft],
);
const removeAttachment = useCallback(
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element") {
const selectedKey = getAttachmentKey(selected);
const { attachmentsByScope } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
return true;
}
suppressWorkspaceAttachment(selected);
return true;
}
return false;
},
[setWorkspaceAttachments, suppressWorkspaceAttachment],
);
const openAttachment = useCallback(
({ attachment }: OpenWorkspaceAttachmentInput) => {
if (!isWorkspaceAttachment(attachment) || attachment.kind !== "review") {
return false;
}
onOpenWorkspaceAttachment?.(attachment);
return true;
},
[onOpenWorkspaceAttachment],
);
const resetSuppression = useCallback(() => {
setSuppressedKeys([]);
}, []);
const completeSubmit = useCallback(
({ result, outgoingAttachments }: CompleteSubmitInput) => {
if (result === "submitted") {
clearSentAttachments(outgoingAttachments);
}
if (result === "queued" || result === "submitted") {
resetSuppression();
}
},
[clearSentAttachments, resetSuppression],
);
return {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
};
}
interface RenderWorkspaceAttachmentPillArgs {
attachment: WorkspaceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
}
interface WorkspaceAttachmentPillProps extends Omit<
RenderWorkspaceAttachmentPillArgs,
"attachment"
> {
attachment: WorkspaceComposerAttachment;
}
function WorkspaceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
}: WorkspaceAttachmentPillProps) {
let label: string;
if (attachment.kind === "browser_element") {
label = `Element · ${attachment.attachment.tag}`;
} else {
label =
attachment.commentCount === 1
? "Review · 1 comment"
: `Review · ${attachment.commentCount} comments`;
}
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={
attachment.kind === "browser_element"
? "Open browser element attachment"
: "Open review attachment"
}
removeAccessibilityLabel={
attachment.kind === "browser_element"
? "Remove browser element attachment"
: "Remove review attachment"
}
disabled={disabled}
>
<View style={styles.pillBody}>
<View style={styles.pillIcon}>
{attachment.kind === "browser_element" ? (
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
)}
</View>
<Text style={styles.pillText} numberOfLines={1}>
{label}
</Text>
</View>
</AttachmentPill>
);
}
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const styles = StyleSheet.create((theme: Theme) => ({
pillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
pillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
pillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
})) as unknown as Record<string, object>;
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -14,21 +14,6 @@ const fileSystemMock = vi.hoisted(() => ({
readAsStringAsync: vi.fn(async () => "AAECAw=="),
deleteAsync: vi.fn(async () => {}),
readDirectoryAsync: vi.fn(async () => []),
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
}));
vi.mock("expo-file-system", () => ({
File: class {
uri: string;
constructor(uri: string) {
this.uri = uri;
}
write(content: Uint8Array) {
fileSystemMock.fileWrite(this.uri, content);
}
},
}));
vi.mock("expo-file-system/legacy", () => ({
@@ -52,10 +37,9 @@ describe("local file attachment store", () => {
fileSystemMock.readAsStringAsync.mockClear();
fileSystemMock.deleteAsync.mockClear();
fileSystemMock.readDirectoryAsync.mockClear();
fileSystemMock.fileWrite.mockClear();
});
it("writes raw byte sources directly to the managed file path", async () => {
it("writes raw base64 sources directly to the managed file path", async () => {
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
@@ -66,14 +50,14 @@ describe("local file attachment store", () => {
id: "preview_8_test",
mimeType: "image/png",
fileName: "result.png",
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
source: { kind: "base64", base64: "AAECAw==" },
});
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
"file:///cache/preview-assets/preview_8_test.png",
new Uint8Array([0, 1, 2, 3]),
"AAECAw==",
{ encoding: "base64" },
);
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
expect(attachment).toMatchObject({
id: "preview_8_test",
mimeType: "image/png",
@@ -1,4 +1,3 @@
import { File } from "expo-file-system";
import * as FileSystem from "expo-file-system/legacy";
import {
type AttachmentStore,
@@ -7,6 +6,7 @@ import {
type SaveAttachmentInput,
} from "@/attachments/types";
import {
blobToBase64,
fileUriToPath,
generateAttachmentId,
getFileExtensionFromName,
@@ -45,15 +45,6 @@ async function ensureDirectory(uri: string): Promise<void> {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
const response = await fetch(dataUrl);
return new Uint8Array(await response.arrayBuffer());
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
async function writeFromSource(input: {
source: SaveAttachmentInput["source"];
targetUri: string;
@@ -68,16 +59,30 @@ async function writeFromSource(input: {
return;
}
let bytes: Uint8Array;
if (input.source.kind === "data_url") {
bytes = await dataUrlToBytes(input.source.dataUrl);
} else if (input.source.kind === "blob") {
bytes = await blobToBytes(input.source.blob);
} else {
bytes = input.source.bytes;
const parsed = parseDataUrl(input.source.dataUrl);
const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType);
const base64 = parsed.base64;
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
if (mimeType !== input.mimeType) {
return;
}
return;
}
new File(input.targetUri).write(bytes);
if (input.source.kind === "base64") {
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
encoding: FileSystem.EncodingType.Base64,
});
return;
}
const base64 = await blobToBase64(input.source.blob);
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
}
function attachmentUri(metadata: AttachmentMetadata): string {
+8 -9
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
@@ -54,20 +54,19 @@ describe("attachment service", () => {
__setAttachmentStoreForTests(null);
});
it("persists raw bytes without requiring a base64 wrapper", async () => {
it("persists raw base64 without requiring a data URL wrapper", async () => {
const store = createRecordingStore();
__setAttachmentStoreForTests(store);
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await persistAttachmentFromBytes({
id: "att_bytes",
bytes,
const attachment = await persistAttachmentFromBase64({
id: "att_base64",
base64: "AAECAw==",
mimeType: "image/png",
fileName: "image.png",
});
expect(attachment).toEqual({
id: "att_bytes",
id: "att_base64",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_1",
@@ -77,10 +76,10 @@ describe("attachment service", () => {
});
expect(store.savedSources).toEqual([
{
id: "att_bytes",
id: "att_base64",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "bytes", bytes },
source: { kind: "base64", base64: "AAECAw==" },
},
]);
});
+3 -3
View File
@@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: {
});
}
export async function persistAttachmentFromBytes(input: {
bytes: Uint8Array;
export async function persistAttachmentFromBase64(input: {
base64: string;
mimeType?: string;
fileName?: string | null;
id?: string;
@@ -42,7 +42,7 @@ export async function persistAttachmentFromBytes(input: {
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
source: { kind: "bytes", bytes: input.bytes },
source: { kind: "base64", base64: input.base64 },
});
}
+3 -47
View File
@@ -1,4 +1,4 @@
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -17,57 +17,13 @@ export interface AttachmentMetadata {
createdAt: number;
}
export interface BrowserElementAttachment {
url: string;
selector: string;
tag: string;
text: string;
outerHTML: string;
computedStyles: Record<string, string>;
boundingRect: {
x: number;
y: number;
width: number;
height: number;
};
reactSource: {
fileName: string | null;
lineNumber: number | null;
columnNumber: number | null;
componentName: string | null;
} | null;
parentChain: string[];
children: string[];
formatted: string;
}
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem }
| {
kind: "browser_element";
attachment: BrowserElementAttachment;
}
| {
kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>;
reviewDraftKey: string;
commentCount: number;
};
export type UserComposerAttachment = Exclude<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
export type WorkspaceComposerAttachment = Extract<
ComposerAttachment,
{ kind: "review" } | { kind: "browser_element" }
>;
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "bytes"; bytes: Uint8Array }
| { kind: "base64"; base64: string }
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
| { kind: "file_uri"; uri: string };
@@ -1,112 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
type Listener = () => void;
class FakeRequest<T = unknown> {
result!: T;
error: Error | null = null;
private listeners = new Map<string, Listener[]>();
addEventListener(event: string, listener: Listener): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
}
emit(event: string): void {
for (const listener of this.listeners.get(event) ?? []) {
listener();
}
}
}
class FakeObjectStore {
constructor(private readonly onPut: (record: unknown) => void) {}
put(record: unknown): FakeRequest<unknown> {
this.onPut(record);
const request = new FakeRequest<unknown>();
queueMicrotask(() => request.emit("success"));
return request;
}
}
class FakeTransaction {
error: Error | null = null;
constructor(private readonly store: FakeObjectStore) {}
objectStore(): FakeObjectStore {
return this.store;
}
addEventListener(): void {}
}
class FakeDatabase {
readonly objectStoreNames = {
contains: () => true,
};
constructor(private readonly store: FakeObjectStore) {}
createObjectStore(): void {}
transaction(): FakeTransaction {
return new FakeTransaction(this.store);
}
close(): void {}
}
describe("indexeddb attachment store", () => {
let storedRecord: unknown;
beforeEach(() => {
storedRecord = null;
Object.defineProperty(globalThis, "indexedDB", {
configurable: true,
value: {
open: () => {
const store = new FakeObjectStore((record) => {
storedRecord = record;
});
const request = new FakeRequest<FakeDatabase>();
request.result = new FakeDatabase(store);
queueMicrotask(() => request.emit("success"));
return request;
},
} as unknown as IDBFactory,
});
});
afterEach(() => {
Reflect.deleteProperty(globalThis, "indexedDB");
});
it("stores raw byte sources as a Blob", async () => {
const store = createIndexedDbAttachmentStore();
const bytes = new Uint8Array([0, 1, 2, 3]);
const attachment = await store.save({
id: "att_bytes",
mimeType: "image/png",
fileName: "image.png",
source: { kind: "bytes", bytes },
});
expect(storedRecord).toEqual({
id: "att_bytes",
blob: new Blob([bytes], { type: "image/png" }),
createdAt: expect.any(Number),
fileName: "image.png",
});
expect(attachment).toMatchObject({
id: "att_bytes",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "att_bytes",
fileName: "image.png",
byteSize: 4,
});
});
});
@@ -74,18 +74,17 @@ function runTx<T>(
});
}
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
const binary = atob(input.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: input.mimeType });
}
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
const source = input.source;
if (source.kind === "bytes") {
const mimeType = normalizeMimeType(input.mimeType);
const buffer = new ArrayBuffer(source.bytes.byteLength);
new Uint8Array(buffer).set(source.bytes);
return {
blob: new Blob([buffer], { type: mimeType }),
mimeType,
};
}
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob =
@@ -106,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
};
}
if (source.kind === "base64") {
const mimeType = normalizeMimeType(input.mimeType);
return {
blob: base64ToBlob({ base64: source.base64, mimeType }),
mimeType,
};
}
const response = await fetch(source.uri);
const blob = await response.blob();
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "./types";
import {
buildWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "./workspace-attachments-store";
function reviewAttachment(body: string): WorkspaceComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,1 +40,1 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
},
};
}
describe("workspace attachments store", () => {
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
expect(
buildWorkspaceAttachmentScopeKey({
serverId: " local ",
workspaceId: " workspace-1 ",
cwd: "/repo",
}),
).toBe("workspace-attachments:server=local:workspace=workspace-1");
expect(
buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: null,
cwd: "/repo/",
}),
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
});
it("publishes and clears attachments for a workspace scope", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: "workspace-1",
cwd: "/repo",
});
const attachment = reviewAttachment("Please simplify this.");
useWorkspaceAttachmentsStore
.getState()
.setWorkspaceAttachments({ scopeKey, attachments: [attachment] });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
attachment,
]);
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});
@@ -1,115 +0,0 @@
import { useMemo } from "react";
import { create } from "zustand";
import type { WorkspaceComposerAttachment } from "./types";
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
export interface WorkspaceAttachmentScopeInput {
serverId: string;
workspaceId?: string | null;
cwd: string;
}
interface WorkspaceAttachmentsStoreState {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
}
interface WorkspaceAttachmentsStoreActions {
setWorkspaceAttachments: (input: {
scopeKey: string;
attachments: readonly WorkspaceComposerAttachment[];
}) => void;
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
}
type WorkspaceAttachmentsStore = WorkspaceAttachmentsStoreState & WorkspaceAttachmentsStoreActions;
function encodeScopePart(value: string): string {
return encodeURIComponent(value.trim());
}
function normalizeCwd(cwd: string): string {
const trimmed = cwd.trim();
if (trimmed === "/") {
return trimmed;
}
return trimmed.replace(/\/+$/, "");
}
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim();
const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;
return ["workspace-attachments", `server=${encodeScopePart(input.serverId)}`, workspacePart].join(
":",
);
}
function areWorkspaceAttachmentsEqual(
left: readonly WorkspaceComposerAttachment[],
right: readonly WorkspaceComposerAttachment[],
): boolean {
if (left === right) {
return true;
}
if (left.length !== right.length) {
return false;
}
return left.every((attachment, index) => attachment === right[index]);
}
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
attachmentsByScope: {},
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
set((state) => {
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
if (areWorkspaceAttachmentsEqual(current, attachments)) {
return state;
}
if (attachments.length === 0) {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
}
return {
attachmentsByScope: {
...state.attachmentsByScope,
[scopeKey]: attachments,
},
};
});
},
clearWorkspaceAttachments: ({ scopeKey }) => {
set((state) => {
if (!state.attachmentsByScope[scopeKey]) {
return state;
}
const next = { ...state.attachmentsByScope };
delete next[scopeKey];
return { attachmentsByScope: next };
});
},
}));
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const { serverId, workspaceId, cwd } = input;
return useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[serverId, workspaceId, cwd],
);
}
export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceComposerAttachment[] {
return useWorkspaceAttachmentsStore(
(state) => state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS,
);
}
export function resetWorkspaceAttachmentsStore(): void {
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
}
@@ -196,7 +196,7 @@ export function AdaptiveModalSheet({
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
onClose,
@@ -228,7 +228,6 @@ export function AdaptiveModalSheet({
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
+55 -336
View File
@@ -1,35 +1,17 @@
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import { Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
interface DirectConnectionDraft {
host: string;
port: string;
useTls: boolean;
password: string;
}
interface PreparedDirectConnection {
uri: string;
endpoint: string;
useTls: boolean;
password?: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -48,66 +30,6 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
},
portRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
hostField: {
flex: 1,
minWidth: 0,
},
portField: {
width: 112,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
passwordInput: {
flex: 1,
minWidth: 0,
},
iconButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1],
},
advancedText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -123,51 +45,8 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
return {
uri: serializeConnectionUri(parsed),
endpoint,
useTls: parsed.useTls,
...(parsed.password ? { password: parsed.password } : {}),
};
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
host: parsed.host,
port: String(parsed.port),
useTls: parsed.useTls,
password: parsed.password ?? "",
};
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
@@ -177,7 +56,7 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
const unique = Array.from(
new Set(
details
@@ -224,9 +103,7 @@ function buildConnectionFailureCopy(
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
@@ -244,7 +121,9 @@ function buildConnectionFailureCopy(
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
@@ -270,41 +149,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState("6767");
const [useTls, setUseTls] = useState(false);
const [password, setPassword] = useState("");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const clearInput = useCallback(() => {
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
endpointRawRef.current = "";
hostInputRef.current?.clear();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
() => [styles.checkbox, useTls ? styles.checkboxChecked : null],
[useTls],
);
const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []);
const useTlsAccessibilityState = useMemo(
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -323,11 +182,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
let connection: PreparedDirectConnection;
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
try {
connection = prepareDirectConnection({ host, port, useTls, password });
endpoint = normalizeHostPort(raw);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid connection";
const message = error instanceof Error ? error.message : "Invalid host:port";
setErrorMessage(message);
return;
}
@@ -339,24 +208,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
endpoint,
});
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
@@ -367,23 +232,17 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [
daemons,
handleClose,
host,
isMobile,
isSaving,
onSaved,
password,
port,
upsertDirectConnection,
useTls,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
const handleSubmitEditing = useCallback(() => {
void handleSave();
@@ -393,43 +252,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
}, [isSaving]);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((current) => !current);
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
} catch {
setAdvancedUri("");
}
setErrorMessage("");
setIsAdvancedOpen(true);
return;
}
try {
const next = draftFromConnectionUri(advancedUri);
setHost(next.host);
setPort(next.port);
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
} catch {
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -439,127 +261,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="next"
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
</View>
</View>
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.palette.white} />
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={handleChangeEndpoint}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
@@ -1,419 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import { AgentStreamView } from "./agent-stream-view";
const assistantMessageCalls = vi.hoisted(
() => [] as Array<{ message: string; spacing: string | undefined }>,
);
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
const mockSessionState = vi.hoisted(() => ({
sessions: {
server: {
client: null,
agentStreamHead: new Map<string, StreamItem[]>(),
workspaces: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
},
},
setAgentTimelineOlderFetchInFlight: () => {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (
factory: (theme: {
borderRadius: Record<string, number>;
borderWidth: Record<number, number>;
colors: Record<string, string>;
fontSize: Record<string, number>;
fontWeight: Record<string, string>;
shadow: Record<string, object>;
spacing: Record<number, number>;
}) => unknown,
) =>
factory({
borderRadius: {
full: 9999,
md: 6,
},
borderWidth: {
1: 1,
},
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
border: "#333",
borderAccent: "#444",
},
fontSize: {
sm: 14,
base: 16,
xs: 12,
},
fontWeight: {
normal: "normal",
},
shadow: {
sm: {},
},
spacing: {
1: 4,
2: 8,
3: 12,
4: 16,
12: 48,
},
}),
},
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
withUnistyles: (Component: unknown) => Component,
}));
vi.mock("react-native-reanimated", async () => {
const ReactModule = await import("react");
return {
default: {
View: ({ children, ...props }: { children?: React.ReactNode }) =>
ReactModule.createElement("div", props, children),
},
Easing: { linear: vi.fn() },
FadeIn: { duration: vi.fn(() => undefined) },
FadeOut: { duration: vi.fn(() => undefined) },
cancelAnimation: vi.fn(),
useAnimatedStyle: (factory: () => unknown) => factory(),
useSharedValue: (value: unknown) => ({ value }),
withRepeat: (value: unknown) => value,
withTiming: (value: unknown) => value,
};
});
vi.mock("lucide-react-native", async () => {
const ReactModule = await import("react");
const Icon = () => ReactModule.createElement("span");
return {
Check: Icon,
ChevronDown: Icon,
X: Icon,
};
});
vi.mock("./message", async () => {
const ReactModule = await import("react");
return {
ActivityLog: () => null,
AssistantMessage: (props: { message: string; spacing?: string }) => {
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
return ReactModule.createElement("div", {
"data-message": props.message,
"data-spacing": props.spacing ?? "",
});
},
CompactionMarker: () => null,
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
SpeakMessage: () => null,
TodoListCard: () => null,
ToolCall: () => null,
TurnCopyButton: (props: { getContent: () => string }) => {
turnCopyButtonCalls.push(props);
return ReactModule.createElement("button", {
"data-testid": "turn-copy-button",
type: "button",
});
},
UserMessage: () => null,
};
});
vi.mock("./tool-call-sheet", async () => {
const ReactModule = await import("react");
return {
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
ReactModule.createElement(ReactModule.Fragment, null, children),
useToolCallSheet: () => ({ open: vi.fn() }),
};
});
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
vi.mock("@/hooks/use-file-explorer-actions", () => ({
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
}));
vi.mock("@/stores/panel-store", () => ({
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
openFileExplorerForCheckout: vi.fn(),
setExplorerTabForCheckout: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: Object.assign(
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
{
getState: () => mockSessionState,
},
),
}));
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
function assistantBlock(params: {
id: string;
text: string;
blockIndex: number;
}): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id: params.id,
blockGroupId: "group-1",
blockIndex: params.blockIndex,
text: params.text,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
};
}
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: new Date("2026-05-01T00:00:00.000Z"),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "bash",
arguments: "npm test",
result: null,
status: "executing",
},
},
};
}
describe("AgentStreamView", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
beforeEach(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
originalScrollTo = HTMLElement.prototype.scrollTo;
HTMLElement.prototype.scrollTo = vi.fn();
assistantMessageCalls.length = 0;
turnCopyButtonCalls.length = 0;
mockSessionState.sessions.server.agentStreamHead = new Map();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
if (originalScrollTo) {
HTMLElement.prototype.scrollTo = originalScrollTo;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
}
vi.restoreAllMocks();
});
it("compacts assistant block spacing across the history/live-head boundary", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "First paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-1:head",
text: "Second paragraph",
blockIndex: 1,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
const streamItems = [tailBlock];
const pendingPermissions = new Map();
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems,
pendingPermissions,
}),
);
});
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
expect(tailCalls.length).toBeGreaterThan(0);
expect(headCalls.length).toBeGreaterThan(0);
expect(tailCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: tailCalls.length }, () => "compactBottom"),
);
expect(headCalls.map((call) => call.spacing)).toEqual(
Array.from({ length: headCalls.length }, () => "compactTop"),
);
});
it("renders running dots in the assistant turn footer when live text is streaming", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
});
it("only renders running dots on the live assistant row", () => {
const tailBlock = assistantBlock({
id: "group-1:block:0",
text: "History paragraph",
blockIndex: 0,
});
const headBlock = assistantBlock({
id: "group-2:head",
text: "Streaming paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [tailBlock],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).toBeNull();
});
it("keeps the auxiliary running dots when there is no live assistant row", () => {
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
const agent = {
id: "agent-1",
serverId: "server",
status: "running",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
).not.toBeNull();
});
it("replaces the running footer with the copy button when the assistant turn idles", () => {
const headBlock = assistantBlock({
id: "group-1:head",
text: "Complete paragraph",
blockIndex: 0,
});
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
const agent = {
id: "agent-1",
serverId: "server",
status: "idle",
cwd: "/tmp/project",
} as never;
act(() => {
root?.render(
React.createElement(AgentStreamView, {
agentId: "agent-1",
serverId: "server",
agent,
streamItems: [],
pendingPermissions: new Map(),
}),
);
});
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
);
});
});
+99 -184
View File
@@ -1,4 +1,4 @@
import React, {
import {
forwardRef,
memo,
useCallback,
@@ -18,7 +18,7 @@ import {
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -56,8 +56,6 @@ import type {
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
@@ -86,7 +84,6 @@ import {
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -128,12 +125,6 @@ const getAssistantBlockSpacing = (params: {
}
return "default";
};
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
}
export interface AgentStreamViewHandle {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -147,7 +138,6 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
@@ -161,12 +151,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
},
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -202,11 +192,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
serverId: resolvedServerId,
agentId,
toast,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
@@ -286,13 +271,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath });
},
[handleInlinePathPress],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
tail: streamItems,
@@ -301,25 +279,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(() => {
if (agent.status !== "running") {
return null;
}
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
if (item.kind !== "assistant_message") {
return false;
}
return (
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) === undefined
);
});
return footerItem?.id ?? null;
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
useImperativeHandle(
ref,
() => ({
@@ -337,9 +296,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
const looseGap = theme.spacing[4]; // 16px
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
@@ -416,7 +375,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
@@ -431,7 +389,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams,
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
@@ -440,17 +398,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
index,
relation: "above",
}) ??
seams.aboveItem ??
undefined;
const belowItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) ??
seams.belowItem ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
@@ -530,7 +485,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
}
@@ -545,11 +499,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
);
const renderStreamItemContent = useCallback(
@@ -557,14 +510,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
seamAboveItem: StreamItem | null = null,
) => {
switch (item.kind) {
case "user_message":
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
return renderUserMessageItem(item, index, items, seamAboveItem);
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seams);
return renderAssistantMessageItem(item, index, items, seamAboveItem);
case "thought":
return renderThoughtItem(item, index, items);
@@ -600,9 +553,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
seamAboveItem: StreamItem | null = null,
) => {
const content = renderStreamItemContent(item, index, items, seams);
const content = renderStreamItemContent(item, index, items, seamAboveItem);
if (!content) {
return null;
}
@@ -618,31 +571,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const isRunningAssistantTurnFooter =
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{footer}
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
</StreamItemWrapper>
);
},
[
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
],
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -650,8 +593,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const showWorkingIndicator = agent.status === "running";
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
@@ -665,12 +607,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workingIndicatorNode = useMemo(
() =>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
showWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator />
</View>
) : null,
[showAuxiliaryWorkingIndicator],
[showWorkingIndicator],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
@@ -712,7 +654,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
@@ -728,12 +669,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (historyIndex === undefined) {
return null;
}
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
return renderStreamItem(item, historyIndex, historyItems, {
belowItem: seamBelowItem,
});
return renderStreamItem(item, historyIndex, historyItems);
},
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
[historyIndexById, historyItems, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -745,9 +683,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, {
aboveItem: index === 0 ? lastHistoryItem : null,
}),
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
@@ -805,9 +741,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -842,7 +775,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
function WorkingIndicator() {
const progress = useSharedValue(0);
useEffect(() => {
@@ -894,13 +827,8 @@ function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={containerStyle}>
<View style={stylesheet.workingIndicatorBubble}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
@@ -910,14 +838,6 @@ function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
@@ -962,33 +882,16 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
const primaryColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const mutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const pressableStyle = ({
pressed,
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
hovered ? permissionStyles.optionButtonHovered : null,
pressed ? permissionStyles.optionButtonPressed : null,
];
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
isPrimary: boolean;
Icon: typeof ThemedCheckIcon;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
@@ -996,21 +899,37 @@ function PermissionActionButton({
action,
isRespondingAction,
isResponding,
isPrimary,
textColor,
iconColor,
isDanger,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const optionTextStyle = isPrimary ? optionTextPrimaryStyle : permissionStyles.optionText;
const colorMapping = isPrimary ? primaryColorMapping : mutedColorMapping;
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} uniProps={colorMapping} />
<Icon size={14} color={iconColor} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
@@ -1025,6 +944,7 @@ function PermissionRequestCard({
permission: PendingPermission;
client: DaemonClient | null;
}) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const { request } = permission;
@@ -1140,6 +1060,10 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
@@ -1147,6 +1071,24 @@ function PermissionRequestCard({
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
@@ -1160,15 +1102,18 @@ function PermissionRequestCard({
const footer = (
<>
<Text testID="permission-request-question" style={permissionStyles.question}>
<Text testID="permission-request-question" style={questionTextStyle}>
How would you like to proceed?
</Text>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
const isRespondingAction = respondingActionId === action.id;
const Icon = action.behavior === "allow" ? ThemedCheckIcon : ThemedXIcon;
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
@@ -1181,9 +1126,12 @@ function PermissionRequestCard({
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
isPrimary={isPrimary}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon}
testID={testID}
theme={theme}
onPress={handleActionPress}
/>
);
@@ -1199,25 +1147,19 @@ function PermissionRequestCard({
description={description}
text={planMarkdown}
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
/>
);
}
return (
<View style={permissionStyles.container}>
<Text style={permissionStyles.title}>{title}</Text>
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
{description ? <Text style={permissionStyles.description}>{description}</Text> : null}
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard
title="Proposed plan"
text={planMarkdown}
testID="permission-plan-card"
disableOuterSpacing
/>
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
) : null}
{!isPlanRequest ? (
@@ -1282,17 +1224,6 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
inlineTurnFooter: {
alignSelf: "flex-start",
marginTop: theme.spacing[2],
padding: theme.spacing[2],
paddingTop: 0,
},
inlineWorkingIndicatorFrame: {
height: 18,
alignItems: "center",
justifyContent: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
@@ -1371,18 +1302,14 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[2],
@@ -1394,7 +1321,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
},
optionsContainer: {
gap: theme.spacing[2],
@@ -1411,11 +1337,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
alignItems: "center",
borderWidth: theme.borderWidth[1],
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
optionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
optionButtonPressed: {
opacity: 0.9,
@@ -1428,15 +1349,9 @@ const permissionStyles = StyleSheet.create((theme) => ({
optionText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
optionTextPrimary: {
color: theme.colors.foreground,
},
}));
const optionTextPrimaryStyle = [permissionStyles.optionText, permissionStyles.optionTextPrimary];
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
File diff suppressed because it is too large Load Diff
@@ -1,48 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMemo } from "react";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
@@ -1,51 +0,0 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface BrowserPaneProps {
browserId: string;
serverId: string;
workspaceId: string;
cwd: string | null;
isInteractive?: boolean;
onFocusPane?: () => void;
}
export function BrowserPane({ browserId }: BrowserPaneProps) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.container}>
<Text style={titleStyle}>Browser is desktop-only</Text>
<Text style={subtitleStyle}>
Open this workspace in Electron to use the built-in browser.
</Text>
<Text style={subtitleStyle}>Browser session {browserId}</Text>
</View>
);
}
const styles = StyleSheet.create(() => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
},
title: {
fontSize: 16,
fontWeight: "600",
},
subtitle: {
fontSize: 12,
},
}));
@@ -55,15 +55,15 @@ vi.mock("lucide-react-native", () => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { SidebarCallout } from "./sidebar-callout";
import { CalloutCard } from "./callout-card";
type SidebarCalloutActions = React.ComponentProps<typeof SidebarCallout>["actions"];
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
function buildSingleAction(onPress: () => void): SidebarCalloutActions {
function buildSingleAction(onPress: () => void): CalloutCardActions {
return [{ label: "Undo", onPress }];
}
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): SidebarCalloutActions {
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
return [
{ label: "What's new", onPress: onWhatsNew },
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
@@ -72,7 +72,7 @@ function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): Sidebar
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
describe("SidebarCallout", () => {
describe("CalloutCard", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -96,7 +96,7 @@ describe("SidebarCallout", () => {
it("renders title and description", () => {
act(() => {
root?.render(
<SidebarCallout title="Update available" description="v1.2.3 is ready to install." />,
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
);
});
@@ -106,7 +106,7 @@ describe("SidebarCallout", () => {
it("renders an icon next to the title", () => {
act(() => {
root?.render(<SidebarCallout title="Update available" icon={calloutTitleIcon} />);
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
@@ -116,7 +116,7 @@ describe("SidebarCallout", () => {
const onPress = vi.fn();
const actions = buildSingleAction(onPress);
act(() => {
root?.render(<SidebarCallout description="Saved." actions={actions} testID="callout" />);
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
});
const button = container?.querySelector(
@@ -130,7 +130,7 @@ describe("SidebarCallout", () => {
const actions = buildTwoActions(vi.fn(), vi.fn());
act(() => {
root?.render(
<SidebarCallout
<CalloutCard
title="Update available"
description="v1 ready."
actions={actions}
@@ -149,7 +149,7 @@ describe("SidebarCallout", () => {
it("renders no action row when no actions are provided", () => {
act(() => {
root?.render(<SidebarCallout description="Copied" testID="callout" />);
root?.render(<CalloutCard description="Copied" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
@@ -158,7 +158,7 @@ describe("SidebarCallout", () => {
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
const onDismiss = vi.fn();
act(() => {
root?.render(<SidebarCallout description="Saved" onDismiss={onDismiss} testID="callout" />);
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
});
const dismissButton = container?.querySelector(
@@ -169,7 +169,7 @@ describe("SidebarCallout", () => {
it("omits the dismiss button when onDismiss is not provided", () => {
act(() => {
root?.render(<SidebarCallout description="Saved" testID="callout" />);
root?.render(<CalloutCard description="Saved" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
@@ -3,33 +3,33 @@ import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type SidebarCalloutActionVariant = "primary" | "secondary";
export type CalloutActionVariant = "primary" | "secondary";
export interface SidebarCalloutAction {
export interface CalloutAction {
label: string;
onPress: () => void;
variant?: SidebarCalloutActionVariant;
variant?: CalloutActionVariant;
disabled?: boolean;
testID?: string;
}
export type SidebarCalloutVariant = "default" | "success" | "error";
export type CalloutVariant = "default" | "success" | "error";
export interface SidebarCalloutProps {
export interface CalloutCardProps {
title?: string;
description?: ReactNode;
icon?: ReactNode;
variant?: SidebarCalloutVariant;
actions?: readonly SidebarCalloutAction[];
variant?: CalloutVariant;
actions?: readonly CalloutAction[];
onDismiss?: () => void;
testID?: string;
}
export function SidebarCalloutDescriptionText({ children }: { children: ReactNode }) {
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
return <Text style={styles.description}>{children}</Text>;
}
export function SidebarCallout({
export function CalloutCard({
title,
description,
icon,
@@ -37,7 +37,7 @@ export function SidebarCallout({
actions,
onDismiss,
testID,
}: SidebarCalloutProps) {
}: CalloutCardProps) {
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
@@ -84,7 +84,7 @@ export function SidebarCallout({
) : null}
{hasDescription && typeof description === "string" ? (
<SidebarCalloutDescriptionText>{description}</SidebarCalloutDescriptionText>
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
@@ -93,7 +93,7 @@ export function SidebarCallout({
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<SidebarCalloutActionButton
<CalloutActionButton
key={action.label}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
@@ -106,13 +106,7 @@ export function SidebarCallout({
);
}
function SidebarCalloutActionButton({
action,
testID,
}: {
action: SidebarCalloutAction;
testID?: string;
}) {
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
const labelStyle = useMemo(
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
+3 -6
View File
@@ -2,14 +2,11 @@ import type { Theme } from "@/styles/theme";
/**
* Compute the pixel width for a line-number gutter based on the highest
* line number that will be displayed and the gutter font size. Minimum
* width accommodates 2 digits. The 0.62 factor approximates monospace
* digit width as a fraction of font size.
* line number that will be displayed. Minimum width accommodates 2 digits.
*/
export function lineNumberGutterWidth(maxLineNumber: number, fontSize: number): number {
export function lineNumberGutterWidth(maxLineNumber: number): number {
const digits = Math.max(2, String(maxLineNumber).length);
const digitWidth = Math.ceil(fontSize * 0.62);
return digits * digitWidth + 12;
return digits * 8 + 12;
}
export function getCodeInsets(theme: Theme) {
@@ -1,5 +1,4 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
@@ -18,14 +17,6 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (composerWorkspaceAttachment.is(attachment)) {
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
}
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
+44 -424
View File
@@ -4,16 +4,10 @@ import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review";
const keyboardActionHandlerMock = vi.hoisted(() => vi.fn());
@@ -28,12 +22,12 @@ const {
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
appSendBehavior,
} = vi.hoisted(() => {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
@@ -42,8 +36,6 @@ const {
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
opacity: { 50: 0.5 },
shadow: { md: {} },
colors: {
surface0: "#000",
@@ -108,12 +100,7 @@ const {
cancelAgent: vi.fn(async () => {}),
};
const hoistedSetQueuedMessagesMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = hoistedMockSessionState.sessions[serverId];
session.queuedMessages = updater(session.queuedMessages);
},
);
const hoistedSetQueuedMessagesMock = vi.fn();
const hoistedMockSessionState: {
sessions: Record<
string,
@@ -174,8 +161,8 @@ const {
);
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
const hoistedMarkScrollInvestigationRenderMock = vi.fn();
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" };
return {
theme: hoistedTheme,
@@ -188,12 +175,12 @@ const {
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock: hoistedMarkScrollInvestigationRenderMock,
mockSessionState: hoistedMockSessionState,
setAgentStreamTailMock: hoistedSetAgentStreamTailMock,
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
appSendBehavior: hoistedAppSendBehavior,
};
});
@@ -201,7 +188,6 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
@@ -226,9 +212,7 @@ vi.mock("lucide-react-native", () => {
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"),
MousePointer2: createIcon("MousePointer2"),
GitPullRequest: createIcon("GitPullRequest"),
MessageSquareCode: createIcon("MessageSquareCode"),
X: createIcon("X"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
@@ -315,7 +299,7 @@ vi.mock("@/utils/open-external-url", () => ({
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }),
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
@@ -354,6 +338,11 @@ vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/scroll-jank", () => ({
markScrollInvestigationRender: markScrollInvestigationRenderMock,
markScrollInvestigationEvent: vi.fn(),
}));
vi.mock("@/components/agent-status-bar", () => ({
AgentStatusBar: () => null,
DraftAgentStatusBar: () => null,
@@ -573,118 +562,9 @@ let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
let workspaceBindingRenderCount = 0;
type ReviewComposerAttachment = Extract<ComposerAttachment, { kind: "review" }>;
type ReviewAttachment = Extract<AgentAttachment, { type: "review" }>;
type BrowserElementComposerAttachment = Extract<ComposerAttachment, { kind: "browser_element" }>;
function reviewAttachment(body: string): ReviewAttachment {
return {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
}
function reviewComposerAttachment(body: string): ReviewComposerAttachment {
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment: reviewAttachment(body),
};
}
function browserElementComposerAttachment(): BrowserElementComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com/page",
selector: "button.primary",
tag: "button",
text: "Save",
outerHTML: '<button class="primary">Save</button>',
computedStyles: { display: "flex" },
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
reactSource: {
fileName: "src/save-button.tsx",
lineNumber: 12,
columnNumber: 3,
componentName: "SaveButton",
},
parentChain: ["form.settings"],
children: [],
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
},
};
}
function cloneReviewComposerAttachment(
attachment: ReviewComposerAttachment,
): ReviewComposerAttachment {
return {
...attachment,
attachment: {
...attachment.attachment,
comments: attachment.attachment.comments.map((comment) => ({
...comment,
context: {
...comment.context,
targetLine: { ...comment.context.targetLine },
lines: comment.context.lines.map((line) => ({ ...line })),
},
})),
},
};
}
function seedReviewDraft(key: string) {
addReviewDraftComment({
key,
comment: {
id: `${key}:comment`,
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body: "Please simplify this.",
createdAt: "2026-04-21T00:00:00.000Z",
updatedAt: "2026-04-21T00:00:00.000Z",
},
});
}
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
@@ -697,7 +577,6 @@ beforeEach(() => {
attachEvent: vi.fn(),
detachEvent: vi.fn(),
});
vi.stubGlobal("localStorage", dom.window.localStorage);
container = document.createElement("div");
document.body.appendChild(container);
@@ -712,14 +591,13 @@ beforeEach(() => {
deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear();
openExternalUrlMock.mockClear();
markScrollInvestigationRenderMock.mockClear();
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear();
keyboardActionHandlerMock.mockClear();
agentDirectoryStatusMock.mockReset();
agentDirectoryStatusMock.mockReturnValue("ready");
appSendBehavior.current = "interrupt";
workspaceBindingRenderCount = 0;
mockSessionState.sessions.server.serverInfo = {
serverId: "server",
hostname: "test",
@@ -737,10 +615,6 @@ beforeEach(() => {
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
mockSessionState.sessions.server.agents = new Map([
["agent", { status: "idle", lastUsage: null }],
]);
resetReviewDraftStore();
});
afterEach(() => {
@@ -768,30 +642,20 @@ function imageAttachment(id: string): AttachmentMetadata {
function ComposerHarness({
initialText = "",
initialAttachments = [],
workspaceAttachment = null,
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
const workspaceAttachments = React.useMemo(
() => (workspaceAttachment ? [workspaceAttachment] : []),
[workspaceAttachment],
);
latestAttachments = attachments;
const handleChangeAttachments = React.useCallback(
(
updater:
| UserComposerAttachment[]
| ((current: UserComposerAttachment[]) => UserComposerAttachment[]),
) => {
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
@@ -810,7 +674,6 @@ function ComposerHarness({
value={text}
onChangeText={setText}
attachments={attachments}
workspaceAttachments={workspaceAttachments}
onChangeAttachments={handleChangeAttachments}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
@@ -824,8 +687,7 @@ function ComposerHarness({
function renderComposer(
input: {
initialText?: string;
initialAttachments?: UserComposerAttachment[];
workspaceAttachment?: ReviewComposerAttachment | null;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
@@ -835,20 +697,6 @@ function renderComposer(
});
}
function WorkspaceAttachmentBindingHarness({
workspaceAttachment,
}: {
workspaceAttachment: ReviewComposerAttachment;
}) {
workspaceBindingRenderCount += 1;
const { selectedAttachments } = composerWorkspaceAttachment.useBinding({
normalAttachments: [],
workspaceAttachments: [workspaceAttachment],
});
return <div data-testid="workspace-binding-count">{selectedAttachments.length}</div>;
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
@@ -889,6 +737,12 @@ function dispatchAgentInterrupt() {
});
}
function countMessageInputRenders(): number {
return markScrollInvestigationRenderMock.mock.calls.filter(
([componentId]) => componentId === "MessageInput:server:agent",
).length;
}
describe("Composer keyboard shortcuts", () => {
it("interrupts a running agent without clearing a filled draft", async () => {
mockSessionState.sessions.server.agents = new Map([
@@ -1063,261 +917,6 @@ describe("Composer attachments", () => {
);
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const review = reviewComposerAttachment("Please simplify this.");
expect(splitComposerAttachmentsForSubmit([review])).toEqual({
images: [],
attachments: [review.attachment],
});
});
it("serializes browser element workspace attachments as generic text attachments", async () => {
const browserElement = browserElementComposerAttachment();
expect(splitComposerAttachmentsForSubmit([browserElement])).toEqual({
images: [],
attachments: [
{
type: "text",
mimeType: "text/plain",
title: "Browser element · button",
text: browserElement.attachment.formatted,
},
],
});
});
it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => {
const review = reviewComposerAttachment("Stable workspace review.");
act(() => {
root?.render(<WorkspaceAttachmentBindingHarness workspaceAttachment={review} />);
});
await flushAsyncWork();
expect(workspaceBindingRenderCount).toBe(1);
expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1");
act(() => {
root?.render(
<WorkspaceAttachmentBindingHarness
workspaceAttachment={cloneReviewComposerAttachment(review)}
/>,
);
});
await flushAsyncWork();
expect(workspaceBindingRenderCount).toBe(2);
expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1");
});
it("renders and submits a workspace review attachment pill", async () => {
const review = reviewComposerAttachment("Please simplify this.");
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("Review");
expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("1 comment");
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
});
it("clears the included workspace review draft after a successful submit", async () => {
const review = reviewComposerAttachment("Clear submitted review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "review this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("restores only normal attachments when a submit with a workspace review fails", async () => {
const image = imageAttachment("img-failure");
const review = reviewComposerAttachment("This should be sent but not persisted.");
seedReviewDraft(review.reviewDraftKey);
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "review this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"review this",
expect.objectContaining({
attachments: [review.attachment],
}),
);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
expect(getReviewDraftComments(review.reviewDraftKey)).toHaveLength(1);
});
it("clears workspace review suppression after a send lifecycle", async () => {
const review = reviewComposerAttachment("Keep this available for the next message.");
renderComposer({
initialText: "send without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("keeps workspace review suppressed after a failed send", async () => {
const review = reviewComposerAttachment("Do not send this on retry.");
mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down"));
renderComposer({
initialText: "retry without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
1,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith(
2,
"agent",
"retry without review",
expect.objectContaining({
attachments: [],
}),
);
});
it("captures workspace review attachments at queue time", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const initialReview = reviewComposerAttachment("Initial queued review.");
const editedReview = reviewComposerAttachment("Edited after queue.");
renderComposer({
initialText: "queue this",
workspaceAttachment: initialReview,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
renderComposer({
initialText: "",
workspaceAttachment: editedReview,
});
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([initialReview]);
});
it("clears the included workspace review draft after queueing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Clear queued review draft.");
seedReviewDraft(review.reviewDraftKey);
renderComposer({
initialText: "queue this",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined();
});
it("clears workspace review suppression after queueing a message", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const review = reviewComposerAttachment("Queue without this review first.");
renderComposer({
initialText: "queue without review",
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Remove review attachment"]')!);
expect(queryByTestId("composer-review-attachment-pill")).toBeNull();
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>;
expect(queued[0]?.attachments).toEqual([]);
expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull();
});
it("does not restore queued workspace review attachments into live draft attachments when editing", async () => {
appSendBehavior.current = "queue";
mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null });
const image = imageAttachment("img-queued-edit");
const review = reviewComposerAttachment("Queued snapshot.");
renderComposer({
initialText: "queue this",
initialAttachments: [{ kind: "image", metadata: image }],
workspaceAttachment: review,
});
click(document.querySelector('[aria-label="Queue message"]')!);
await flushAsyncWork();
expect(
(
mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{
attachments: ComposerAttachment[];
}>
)[0]?.attachments,
).toEqual([{ kind: "image", metadata: image }, review]);
click(document.querySelector('[aria-label="Edit queued message"]')!);
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });
@@ -1387,6 +986,27 @@ describe("Composer attachments", () => {
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("does not re-render MessageInput when opening the attachment lightbox", () => {
const image = imageAttachment("img-lightbox-render");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const renderCountBeforeLightbox = countMessageInputRenders();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(countMessageInputRenders()).toBe(renderCountBeforeLightbox);
});
it("still re-renders MessageInput when submit loading semantics change", () => {
renderComposer({ initialText: "pending submit", isSubmitLoading: false });
const renderCountBeforeLoading = countMessageInputRenders();
renderComposer({ initialText: "pending submit", isSubmitLoading: true });
expect(countMessageInputRenders()).toBe(renderCountBeforeLoading + 1);
expect(document.querySelector('[aria-label="Send message"]')).toHaveProperty("disabled", true);
});
it("enables dictation from server capabilities before the agent directory finishes loading", () => {
agentDirectoryStatusMock.mockReturnValue("initial_loading");
+73 -140
View File
@@ -7,7 +7,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
@@ -40,7 +40,7 @@ import {
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
@@ -62,6 +62,7 @@ import {
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
@@ -70,13 +71,7 @@ import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
@@ -92,13 +87,13 @@ interface QueuedMessage {
}
type AttachmentListUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
function noop() {}
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
function resolveComposerButtonIconSize(theme: Theme): number {
return isWeb ? theme.iconSize.md : theme.iconSize.lg;
}
function resolveIsComposerLocked(
@@ -151,7 +146,7 @@ async function pickAndPersistImages(
);
}
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
function removeAttachmentAtIndex(prev: ComposerAttachment[], index: number): ComposerAttachment[] {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
@@ -248,28 +243,26 @@ function findGithubItemByOption(
}
function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
attachments: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return composerWorkspaceAttachment
.userAttachmentsOnly(current)
.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return attachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
function buildGithubAttachment(item: GitHubSearchItem): ComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function toggleGithubAttachment(
current: UserComposerAttachment[],
current: ComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
): ComposerAttachment[] {
const matches = (attachment: ComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
@@ -369,15 +362,6 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
index,
disabled,
onOpen,
onRemove,
});
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -463,7 +447,6 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream({ ...args, userMessage });
const imagesData = await encodeImages(wirePayload.images);
@@ -477,16 +460,11 @@ async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Pro
function openComposerAttachment(
attachment: ComposerAttachment,
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
): void {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
if (composerWorkspaceAttachment.is(attachment)) {
openWorkspaceAttachment({ attachment });
return;
}
void openExternalUrl(attachment.item.url);
}
@@ -607,6 +585,7 @@ interface QueuedMessageRowProps {
}
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
const { theme } = useUnistyles();
const handleEdit = useCallback(() => {
onEdit(item.id);
}, [onEdit, item.id]);
@@ -619,19 +598,11 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
{item.text}
</Text>
<View style={styles.queueActions}>
<Pressable
onPress={handleEdit}
style={styles.queueActionButton}
accessibilityLabel="Edit queued message"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={handleSendNow}
style={QUEUE_SEND_BUTTON_STYLE}
accessibilityLabel="Send queued message now"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
<ArrowUp size={theme.iconSize.sm} color="white" />
</Pressable>
</View>
</View>
@@ -683,7 +654,7 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachment: Exclude<ComposerAttachment, { kind: "image" }>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
@@ -697,6 +668,7 @@ function GithubAttachmentPill({
onOpen,
onRemove,
}: GithubAttachmentPillProps) {
const { theme } = useUnistyles();
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const handleOpen = useCallback(() => {
@@ -717,9 +689,9 @@ function GithubAttachmentPill({
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
@@ -747,17 +719,18 @@ function GithubPickerOption({
item,
onToggle,
}: GithubPickerOptionProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(item);
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<GitPullRequest size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
),
[item.kind],
[item.kind, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
@@ -790,9 +763,7 @@ interface ComposerProps {
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
@@ -853,7 +824,9 @@ function ComposerCancelButton({
) : (
<Square size={buttonIconSize} color="white" fill="white" />
);
const shortcutNode = agentInterruptKeys ? <Shortcut chord={agentInterruptKeys} /> : null;
const shortcutNode = agentInterruptKeys ? (
<Shortcut chord={agentInterruptKeys} style={styles.tooltipShortcut} />
) : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -899,6 +872,7 @@ interface ComposerVoiceModeButtonProps {
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
theme: Theme;
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
}
@@ -937,18 +911,21 @@ function ComposerVoiceModeButton({
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const shortcutNode = voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null;
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <AudioLines size={buttonIconSize} color={color} />;
},
[buttonIconSize, isVoiceSwitching],
[buttonIconSize, isVoiceSwitching, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -986,8 +963,6 @@ export function Composer({
value,
onChangeText,
attachments,
workspaceAttachments = [],
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
clearDraft,
@@ -1002,7 +977,9 @@ export function Composer({
statusControls,
inputWrapperStyle,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = resolveComposerButtonIconSize(theme);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1036,19 +1013,7 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const userInput = value;
const setUserInput = onChangeText;
const {
selectedAttachments,
buildOutgoingAttachments,
removeAttachment,
openAttachment,
clearSentAttachments,
completeSubmit,
resetSuppression,
} = composerWorkspaceAttachment.useBinding({
normalAttachments: attachments,
workspaceAttachments,
onOpenWorkspaceAttachment,
});
const selectedAttachments = attachments;
const setSelectedAttachments = onChangeAttachments;
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
@@ -1205,18 +1170,8 @@ export function Composer({
setUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
},
[
agentId,
clearSentAttachments,
resetSuppression,
serverId,
setQueuedMessages,
setSelectedAttachments,
setUserInput,
],
[agentId, serverId, setQueuedMessages, setSelectedAttachments, setUserInput],
);
const sendMessageWithContent = useCallback(
@@ -1225,7 +1180,7 @@ export function Composer({
outgoingAttachments: ComposerAttachment[],
forceSend?: boolean,
) => {
const result = await submitAgentInput({
await submitAgentInput({
message: outgoingMessage,
attachments: outgoingAttachments,
hasExternalContent,
@@ -1245,7 +1200,7 @@ export function Composer({
clearDraft,
setUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
@@ -1253,15 +1208,10 @@ export function Composer({
console.error("[AgentInput] Failed to send message:", error);
},
});
completeSubmit({
result,
outgoingAttachments,
});
},
[
allowEmptySubmit,
clearDraft,
completeSubmit,
hasExternalContent,
isAgentRunning,
queueMessage,
@@ -1277,13 +1227,9 @@ export function Composer({
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
},
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent],
[blurOnSubmit, sendMessageWithContent],
);
const handlePickImage = useCallback(async () => {
@@ -1294,24 +1240,14 @@ export function Composer({
const handleRemoveAttachment = useCallback(
(index: number) => {
const didRemoveWorkspaceAttachment = removeAttachment({
selectedAttachments,
index,
});
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
[setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
},
[openAttachment],
);
const handleOpenAttachment = useCallback((attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata);
}, []);
useEffect(() => {
if (!isAgentRunning || !isConnected) {
@@ -1399,7 +1335,7 @@ export function Composer({
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
setSelectedAttachments(item.attachments);
},
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
);
@@ -1425,9 +1361,9 @@ export function Composer({
const handleQueue = useCallback(
(payload: MessagePayload) => {
queueMessage(payload.text, buildOutgoingAttachments(attachments));
queueMessage(payload.text, payload.attachments);
},
[attachments, buildOutgoingAttachments, queueMessage],
[queueMessage],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -1480,6 +1416,7 @@ export function Composer({
],
);
const isVoiceSwitchingValue = voice?.isVoiceSwitching ?? false;
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
@@ -1491,8 +1428,9 @@ export function Composer({
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
isVoiceSwitching={isVoiceSwitchingValue}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
theme={theme}
voiceToggleKeys={voiceToggleKeys}
cancelButton={cancelButton}
/>
@@ -1507,8 +1445,9 @@ export function Composer({
isConnected,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
isVoiceSwitchingValue,
realtimeVoiceButtonStyle,
theme,
voiceToggleKeys,
],
);
@@ -1552,7 +1491,7 @@ export function Composer({
{
id: "image",
label: "Add image",
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
void handlePickImage();
},
@@ -1560,13 +1499,13 @@ export function Composer({
{
id: "github",
label: "Add issue or PR",
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
);
const handleToggleGithubItem = useCallback(
@@ -1870,6 +1809,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},
@@ -1917,13 +1860,3 @@ const styles = StyleSheet.create((theme: Theme) => ({
})) as unknown as Record<string, object>;
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -138,6 +138,7 @@ const styles = StyleSheet.create((theme) => ({
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
tooltipText: {
color: theme.colors.foreground,
@@ -0,0 +1,209 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const hostsState = vi.hoisted(() => ({
value: [{ serverId: "server-1", label: "Local daemon" }],
}));
const sessionState = vi.hoisted(() => ({
value: {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
},
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => hostsState.value,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
selector(sessionState.value),
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: () => "1.2.3",
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
function Harness() {
return (
<SidebarCalloutProvider>
<DaemonVersionMismatchCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root): Promise<void> {
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("DaemonVersionMismatchCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
sessionState.value = {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
};
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers a daemon mismatch sidebar callout", async () => {
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).not.toBeNull();
expect(container?.textContent).toContain("Daemon version mismatch");
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
expect(container?.textContent).toContain("This app is v1.2.3");
expect(container?.textContent).toContain(
"For the best experience, keep the daemon and client on the same version.",
);
});
it("does not register a callout when versions match", async () => {
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
it("opens host settings from the callout action", async () => {
await renderHarness(root!);
const action = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
) as HTMLElement | null;
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
});
it("persists dismissal for the current app-daemon version pair", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
});
@@ -0,0 +1,79 @@
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
interface DaemonVersionMismatch {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
}
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();
const sessions = useSessionStore((state) => state.sessions);
const appVersion = resolveAppVersion();
return useMemo(() => {
if (!appVersion) {
return [];
}
return hosts
.map((entry) => ({
serverId: entry.serverId,
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
appVersion,
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
}))
.filter((entry): entry is DaemonVersionMismatch =>
isVersionMismatch(entry.appVersion, entry.daemonVersion),
);
}, [appVersion, hosts, sessions]);
}
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
const callouts = useSidebarCallouts();
const router = useRouter();
const { appVersion, daemonVersion, label, serverId } = mismatch;
const openSettings = useStableEvent(() => {
router.navigate(buildSettingsHostRoute(serverId));
});
useEffect(() => {
return callouts.show({
id: `daemon-version-mismatch:${serverId}`,
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
priority: 300,
title: "Daemon version mismatch",
description: `${label} is running ${formatVersionWithPrefix(
daemonVersion,
)}. This app is ${formatVersionWithPrefix(
appVersion,
)}. For the best experience, keep the daemon and client on the same version.`,
variant: "error",
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
testID: `daemon-version-mismatch-callout-${serverId}`,
});
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
return null;
}
export function DaemonVersionMismatchCalloutSource() {
const mismatches = useDaemonVersionMismatches();
return (
<>
{mismatches.map((mismatch) => (
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
))}
</>
);
}
@@ -483,7 +483,6 @@ function SidebarContent({
workspaceId={workspaceId}
cwd={workspaceRoot}
hideHeaderRow={!isMobile}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
+25 -37
View File
@@ -1,6 +1,5 @@
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
@@ -28,9 +27,8 @@ import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { persistAttachmentFromBase64 } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -67,34 +65,30 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
async function createFilePanePreview(file: FileReadResult | null): Promise<{
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file) {
return { file: null, imageAttachment: null };
if (!file || file.kind !== "image" || !file.content) {
return { file, imageAttachment: null };
}
const explorerFile = explorerFileFromReadResult(file);
if (file.kind !== "image") {
return { file: explorerFile, imageAttachment: null };
}
const imageAttachment = await persistAttachmentFromBytes({
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
mimeType: file.mime,
mimeType: file.mimeType ?? "image/png",
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
contentLength: file.content.length,
}),
bytes: file.bytes,
mimeType: file.mime,
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path),
});
return {
file: explorerFile,
file: imageFile,
imageAttachment,
};
}
@@ -118,9 +112,7 @@ const CodeLine = React.memo(function CodeLine({
return (
<View style={codeLineStyles.line}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={gutterTextStyle}>
{String(lineNumber)}
</Text>
<Text style={gutterTextStyle}>{String(lineNumber)}</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
@@ -201,8 +193,8 @@ function FilePreviewBody({
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
}, [highlightedLines, theme.fontSize.sm]);
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -364,21 +356,17 @@ export function FilePane({
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
return { file: null as ExplorerFile | null, error: "Host is not connected" };
}
try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
const preview = await createFilePanePreview(file);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: null,
};
} catch (error) {
return {
file: null,
imageAttachment: null,
error: error instanceof Error ? error.message : "Failed to load file",
};
}
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file",
);
const preview = await createFilePanePreview(payload.file ?? null);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: payload.error ?? null,
};
},
staleTime: 5_000,
refetchOnMount: true,
@@ -36,11 +36,6 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"pull-and-push": {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
@@ -70,7 +65,7 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
});
it("prioritizes pull when the branch is behind origin", () => {
@@ -154,7 +149,6 @@ describe("git-actions-policy", () => {
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
@@ -164,48 +158,6 @@ describe("git-actions-policy", () => {
).toBe(true);
});
it("enables pull-and-push when the branch has both incoming and outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 3,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
disabled: false,
unavailableMessage: undefined,
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
disabled: false,
unavailableMessage: "Pull and push isn't available because this branch is already in sync",
});
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasUncommittedChanges: true,
aheadOfOrigin: 1,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available while you have local changes so commit or stash them first",
);
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
@@ -6,7 +6,6 @@ export type GitActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "pr"
| "merge-branch"
| "merge-from-base"
@@ -57,7 +56,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
@@ -102,20 +101,6 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.push.handler,
});
allActions.set("pull-and-push", {
id: "pull-and-push",
label: "Pull and push",
pendingLabel: "Pulling and pushing...",
successLabel: "Pulled and pushed",
disabled: input.runtime["pull-and-push"].disabled,
status: input.runtime["pull-and-push"].status,
unavailableMessage: input.runtime["pull-and-push"].disabled
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", {
@@ -280,19 +265,6 @@ function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefi
return undefined;
}
function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull and push isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";
File diff suppressed because it is too large Load Diff
@@ -88,5 +88,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
shortcut: {},
shortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
}));
@@ -86,7 +86,6 @@ vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
FolderPlus: createIcon("FolderPlus"),
MessagesSquare: createIcon("MessagesSquare"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
+3 -3
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings } from "lucide-react-native";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -365,7 +365,7 @@ function FooterIconButton({
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof FolderPlus;
icon: typeof Plus;
theme: SidebarTheme;
}) {
return (
@@ -448,7 +448,7 @@ function SidebarFooter({
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
icon={FolderPlus}
icon={Plus}
theme={theme}
/>
</TooltipTrigger>
@@ -47,7 +47,7 @@ vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
+235 -27
View File
@@ -18,8 +18,8 @@ import {
useMemo,
forwardRef,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
@@ -51,6 +51,7 @@ import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { markScrollInvestigationEvent, markScrollInvestigationRender } from "@/utils/scroll-jank";
import { isWeb } from "@/constants/platform";
import { useComposerHeightMirror } from "./composer-height-mirror";
@@ -166,19 +167,46 @@ interface TextAreaHandle {
} & Record<string, unknown>;
}
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return Number(performance.now().toFixed(3));
}
return null;
}
function getElementDescriptor(element: HTMLElement | null): string | null {
if (!element) return null;
const tag = element.tagName?.toLowerCase() ?? "unknown";
const id = element.id ? `#${element.id}` : "";
const testId = element.getAttribute?.("data-testid");
const label = element.getAttribute?.("aria-label");
let suffix: string;
if (testId) suffix = `[data-testid="${testId}"]`;
else if (label) suffix = `[aria-label="${label}"]`;
else suffix = "";
return `${tag}${id}${suffix}`;
}
function AttachButtonIcon({
hovered,
onAttachButtonRef,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
onAttachButtonRef: ((node: View | null) => void) | undefined;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return (
<View ref={onAttachButtonRef} collapsable={false} style={styles.attachButtonAnchor}>
<ThemedPlus size={buttonIconSize} uniProps={colorMapping} />
<Plus size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />
</View>
);
}
@@ -250,20 +278,23 @@ function VoiceButtonIcon({
isDictating,
isMutedRealtime,
buttonIconSize,
foreground,
foregroundMuted,
}: {
hovered: boolean;
isDictating: boolean;
isMutedRealtime: boolean;
buttonIconSize: number;
foreground: string;
foregroundMuted: string;
}) {
if (isDictating) {
return <Square size={buttonIconSize} color="white" fill="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
if (isMutedRealtime) {
return <ThemedMicOff size={buttonIconSize} uniProps={colorMapping} />;
return <MicOff size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />;
}
return <ThemedMic size={buttonIconSize} uniProps={colorMapping} />;
return <Mic size={buttonIconSize} color={hovered ? foreground : foregroundMuted} />;
}
type ShortcutChord = NonNullable<React.ComponentProps<typeof Shortcut>["chord"]>;
@@ -278,7 +309,7 @@ function VoiceTooltipBody({
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{voiceTooltipText}</Text>
{shortcut ? <Shortcut chord={shortcut} /> : null}
{shortcut ? <Shortcut chord={shortcut} style={styles.tooltipShortcut} /> : null}
</View>
);
}
@@ -293,7 +324,7 @@ function SendTooltipBody({
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{sendKeys ? <Shortcut chord={sendKeys} /> : null}
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
);
}
@@ -360,6 +391,7 @@ function resolveSendTooltipLabel(input: {
}
interface DesktopKeyPressContext {
investigationComponentId: string;
onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
@@ -374,6 +406,8 @@ function handleDesktopKeyPressImpl(
event: WebTextInputKeyPressEvent,
ctx: DesktopKeyPressContext,
): void {
markScrollInvestigationEvent(ctx.investigationComponentId, "keyPress");
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
if (ctx.onKeyPressCallback) {
@@ -540,6 +574,107 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
]);
}
interface ResizeObserverEffectArgs {
getWebTextArea: () => TextAreaHandle | null;
getWebElement: (target: "root" | "wrapper") => HTMLElement | null;
valueRef: React.MutableRefObject<string>;
}
function useComposerResizeObserverEffect(args: ResizeObserverEffectArgs): void {
const { getWebTextArea, getWebElement, valueRef } = args;
useEffect(() => {
if (!isWeb || typeof ResizeObserver === "undefined") return;
const textarea = getWebTextArea();
const root = getWebElement("root");
const wrapper = getWebElement("wrapper");
const observed = [
{ name: "composer_root", element: root },
{ name: "composer_wrapper", element: wrapper },
{ name: "composer_textarea", element: textarea as unknown as HTMLElement | null },
].filter(
(entry): entry is { name: string; element: HTMLElement } =>
entry.element instanceof HTMLElement,
);
if (observed.length === 0) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
const match = observed.find((item) => item.element === target);
if (!match) continue;
const textareaNode = getWebTextArea();
logWebStickyBottom("composer_element_resized", {
target: match.name,
width: target.clientWidth,
height: target.clientHeight,
offsetHeight: target.offsetHeight,
scrollHeight: target.scrollHeight,
textareaClientHeight: textareaNode?.clientHeight ?? null,
textareaOffsetHeight: textareaNode?.offsetHeight ?? null,
textareaScrollHeight: textareaNode?.scrollHeight ?? null,
textareaScrollTop:
(textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null,
valueLength: valueRef.current.length,
});
}
});
for (const entry of observed) {
observer.observe(entry.element);
}
return () => {
observer.disconnect();
};
}, [getWebElement, getWebTextArea, valueRef]);
}
interface ScrollLogEffectArgs {
getWebTextArea: () => TextAreaHandle | null;
valueRef: React.MutableRefObject<string>;
}
function useComposerScrollLogEffect(args: ScrollLogEffectArgs): void {
const { getWebTextArea, valueRef } = args;
useEffect(() => {
if (!isWeb) return;
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
if (!textarea || typeof textarea.addEventListener !== "function") return;
const handleScroll = () => {
const textareaElement = textarea as unknown as HTMLElement;
const chatScroller =
typeof document !== "undefined"
? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null)
: null;
logWebStickyBottom("composer_textarea_scrolled", {
now: getDebugNow(),
scrollTop: textarea.scrollTop,
clientHeight: textarea.clientHeight ?? null,
scrollHeight: textarea.scrollHeight ?? null,
selectionStart: textarea.selectionStart ?? null,
selectionEnd: textarea.selectionEnd ?? null,
textareaDescriptor: getElementDescriptor(textareaElement),
chatScrollerDescriptor: getElementDescriptor(chatScroller),
chatScrollerContainsTextarea: Boolean(
chatScroller && textareaElement && chatScroller.contains(textareaElement),
),
textareaScrollableAncestors: getScrollableAncestorChain(textareaElement),
valueLength: valueRef.current.length,
});
};
textarea.addEventListener("scroll", handleScroll, { passive: true });
return () => {
textarea.removeEventListener("scroll", handleScroll);
};
}, [getWebTextArea, valueRef]);
}
function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
@@ -927,6 +1062,13 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
ctx.onMinimizeHeight();
}
function computeInvestigationComponentId(
voiceServerId: string | undefined,
voiceAgentId: string | undefined,
): string {
return `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
}
function computeIsRealtimeVoiceForAgent(
voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
voiceServerId: string | undefined,
@@ -1020,6 +1162,16 @@ function getWebTextAreaImpl(
return null;
}
function toHtmlElement(current: unknown): HTMLElement | null {
if (!current) return null;
if (current instanceof HTMLElement) return current;
const maybe = current as { getBoundingClientRect?: () => DOMRect };
if (maybe.getBoundingClientRect) {
return current as HTMLElement;
}
return null;
}
interface SendButtonStateInput {
disabled: boolean;
isSubmitDisabled: boolean;
@@ -1154,6 +1306,26 @@ function extractErrorMessage(error: unknown): string | null {
return null;
}
function getScrollableAncestorChain(element: HTMLElement | null): string[] {
if (!element || typeof window === "undefined") {
return [];
}
const results: string[] = [];
let current = element.parentElement;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const canScroll =
(overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
current.scrollHeight > current.clientHeight;
if (canScroll) {
results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase());
}
current = current.parentElement;
}
return results;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(props, ref) {
const {
@@ -1193,7 +1365,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onHeightChange,
inputWrapperStyle,
} = resolveMessageInputProps(props);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = computeInvestigationComponentId(voiceServerId, voiceAgentId);
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
const voice = useVoiceOptional();
const sendKeys = useShortcutKeys("message-input-send");
@@ -1328,6 +1503,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -1528,6 +1704,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
});
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
const current = target === "root" ? rootRef.current : inputWrapperRef.current;
return toHtmlElement(current);
}, []);
usePasteImagesEffect({
getWebTextArea,
isConnected,
@@ -1537,6 +1718,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onAddImages,
});
useComposerResizeObserverEffect({ getWebTextArea, getWebElement, valueRef });
useComposerScrollLogEffect({ getWebTextArea, valueRef });
const setBoundedInputHeight = useCallback(
(nextHeight: number) => {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
@@ -1568,9 +1753,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
start,
end,
textareaScrollTop: textarea?.scrollTop ?? null,
textareaClientHeight: textarea?.clientHeight ?? null,
textareaScrollHeight: textarea?.scrollHeight ?? null,
});
}
onSelectionChangeCallback?.({ start, end });
},
[onSelectionChangeCallback],
[getWebTextArea, onSelectionChangeCallback],
);
const shouldHandleDesktopSubmit = isWeb;
@@ -1578,6 +1774,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleDesktopSubmit) return;
handleDesktopKeyPressImpl(event, {
investigationComponentId,
onKeyPressCallback,
isAgentRunning,
onQueue,
@@ -1625,9 +1822,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback(
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue);
if (isWeb) {
logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length,
lineCount: nextValue.split("\n").length,
});
}
},
[onChangeText],
[investigationComponentId, onChangeText],
);
const handleInputFocus = useCallback(() => {
@@ -1688,9 +1892,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
hovered={Boolean(hovered)}
onAttachButtonRef={onAttachButtonRef}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[onAttachButtonRef, buttonIconSize],
[onAttachButtonRef, buttonIconSize, theme.colors.foreground, theme.colors.foregroundMuted],
);
const renderVoiceButtonIcon = useCallback(
@@ -1700,9 +1906,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictating={isDictating}
isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)}
buttonIconSize={buttonIconSize}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
[
isDictating,
isRealtimeVoiceForCurrentAgent,
voice?.isMuted,
buttonIconSize,
theme.colors.foreground,
theme.colors.foregroundMuted,
],
);
return (
@@ -1711,12 +1926,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
uniProps={textInputPlaceholderColorMapping}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={handleInputFocus}
onBlur={handleInputBlur}
@@ -1924,6 +2139,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},
@@ -1940,14 +2159,3 @@ const styles = StyleSheet.create((theme: Theme) => ({
bottom: 0,
},
})) as unknown as Record<string, object>;
const ThemedPlus = withUnistyles(Plus);
const ThemedMic = withUnistyles(Mic);
const ThemedMicOff = withUnistyles(MicOff);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});
+89 -289
View File
@@ -4,7 +4,6 @@ import {
Image,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type LayoutChangeEvent,
StyleProp,
ViewStyle,
@@ -46,10 +45,8 @@ import {
TriangleAlertIcon,
Scissors,
MicVocal,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -64,11 +61,9 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
@@ -80,12 +75,11 @@ import {
} from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
setAssistantImageMetadata,
type AssistantImageLoadState,
} from "@/utils/assistant-image-metadata";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
@@ -98,7 +92,7 @@ import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
export type { InlinePathTarget } from "@/utils/inline-path";
@@ -108,7 +102,6 @@ type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: un
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
@@ -158,26 +151,6 @@ interface MarkdownWithStableRendererProps {
}
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
const ThemedMicVocal = withUnistyles(MicVocal);
const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const mutedForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.mutedForeground,
});
const primaryForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.primaryForeground,
});
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
0% {
@@ -322,7 +295,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
flexDirection: "row",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[2],
...(isWeb ? { userSelect: "text" as const } : {}),
},
content: {
alignItems: "flex-end",
@@ -358,11 +330,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
flexWrap: "wrap",
},
attachmentPreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePreviewSpacing: {
marginBottom: theme.spacing[2],
},
@@ -381,19 +348,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
height: 48,
backgroundColor: theme.colors.surface1,
},
structuredAttachmentPill: {
maxWidth: 220,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
structuredAttachmentText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
@@ -416,37 +370,21 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
}
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
}
case "github_pr":
return `PR #${attachment.number}`;
case "github_issue":
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? "Text attachment";
}
}
export const UserMessage = memo(function UserMessage({
message,
images = [],
attachments = [],
timestamp: _timestamp,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
@@ -457,23 +395,16 @@ export const UserMessage = memo(function UserMessage({
() => [
userMessageStylesheet.container,
!resolvedDisableOuterSpacing && [
isFirstInGroup ? userMessageStylesheet.containerFirstInGroup : null,
isLastInGroup ? userMessageStylesheet.containerLastInGroup : null,
!isFirstInGroup || !isLastInGroup ? userMessageStylesheet.containerSpacing : null,
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup],
[resolvedDisableOuterSpacing, isFirstInGroup, isLastInGroup, theme.spacing],
);
const imagePreviewContainerStyle = useMemo(
() => [
userMessageStylesheet.imagePreviewContainer,
hasText || hasAttachments ? userMessageStylesheet.imagePreviewSpacing : undefined,
],
[hasAttachments, hasText],
);
const attachmentPreviewContainerStyle = useMemo(
() => [
userMessageStylesheet.attachmentPreviewContainer,
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
],
[hasText],
@@ -505,20 +436,6 @@ export const UserMessage = memo(function UserMessage({
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => (
<View
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
</Text>
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
@@ -553,7 +470,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
...(isWeb ? { userSelect: "text" as const } : {}),
},
containerCompactTop: {
paddingTop: 0,
@@ -627,17 +543,17 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
const [loadState, setLoadState] = useState<AssistantImageLoadState>(() =>
getAssistantImageLoadStateFromMetadata(cachedMetadata),
const [aspectRatio, setAspectRatio] = useState<number | null>(
cachedMetadata?.aspectRatio ?? null,
);
useEffect(() => {
if (cachedMetadata) {
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
setAspectRatio(cachedMetadata.aspectRatio);
return;
}
setLoadState({ status: "loading" });
setAspectRatio(null);
let cancelled = false;
Image.getSize(
@@ -651,17 +567,14 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
{ source, workspaceRoot, serverId },
{ width, height },
);
setLoadState({
status: "ready",
aspectRatio: metadata?.aspectRatio ?? width / height,
});
setAspectRatio(metadata?.aspectRatio ?? width / height);
}
},
() => {
if (cancelled) {
return;
}
setLoadState({ status: "error" });
setAspectRatio(null);
},
);
@@ -670,41 +583,19 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
};
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
const handleImageError = useCallback(() => {
setLoadState({ status: "error" });
}, []);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
loadState.status === "ready"
? { aspectRatio: loadState.aspectRatio }
: { height: ASSISTANT_IMAGE_MIN_HEIGHT },
aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT },
],
[loadState],
[aspectRatio],
);
const frameStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageFrame, containerStyle],
[containerStyle],
);
const stateSurfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [surfaceStyle, assistantMessageStylesheet.imageState],
[surfaceStyle],
);
const imageSource = useMemo(() => ({ uri }), [uri]);
if (loadState.status !== "ready") {
return (
<View style={frameStyle}>
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
) : null}
</View>
</View>
);
}
return (
<View style={frameStyle}>
<View style={surfaceStyle}>
@@ -713,7 +604,6 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
style={assistantMessageStylesheet.image}
resizeMode="contain"
accessibilityLabel={alt}
onError={handleImageError}
/>
</View>
</View>
@@ -735,6 +625,7 @@ function AssistantMarkdownImage({
workspaceRoot?: string;
serverId?: string;
}) {
const { theme } = useUnistyles();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
@@ -742,10 +633,10 @@ function AssistantMarkdownImage({
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? 16 : 0,
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
marginBottom: 0,
}),
[hasLeadingContent],
[hasLeadingContent, theme],
);
const query = useQuery({
@@ -762,21 +653,25 @@ function AssistantMarkdownImage({
return null;
}
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
if (payload.error) {
throw new Error(payload.error);
}
const file = payload.file;
if (!file || file.kind !== "image" || !file.content) {
throw new Error("Image preview unavailable.");
}
return await persistAttachmentFromBytes({
return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
mimeType: file.mime,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
contentLength: file.content.length,
}),
bytes: file.bytes,
mimeType: file.mime,
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
@@ -810,7 +705,6 @@ function AssistantMarkdownImage({
() => [
assistantMessageStylesheet.imageFrame,
containerStyle,
{ height: ASSISTANT_IMAGE_MIN_HEIGHT },
assistantMessageStylesheet.imageState,
],
[containerStyle],
@@ -1100,8 +994,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
opacity: 0.72,
},
secondaryLabel: {
flexShrink: 1,
minWidth: 0,
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
@@ -1114,6 +1007,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
color: "transparent",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
flexShrink: 1,
},
spacer: {
flex: 1,
@@ -1122,16 +1016,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
flexShrink: 0,
},
openFileButton: {
marginLeft: theme.spacing[1],
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
openFileButtonPlaceholderIcon: {
width: 14,
height: 14,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
@@ -1146,7 +1030,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
overflow: "hidden",
...(isWeb ? { cursor: "auto" as const, userSelect: "text" as const } : {}),
},
pressableExpanded: {
borderColor: theme.colors.border,
@@ -1331,6 +1214,7 @@ function AssistantMessageBlockContainer({
interface MemoizedMarkdownBlockProps {
text: string;
styles: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
@@ -1338,13 +1222,14 @@ interface MemoizedMarkdownBlockProps {
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text,
styles,
rules,
parser,
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<ThemedMarkdown
uniProps={markdownStyleMapping}
<MarkdownWithStableRenderer
style={styles}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
@@ -1352,27 +1237,22 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</MarkdownWithStableRenderer>
);
});
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
}
function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
const style = useMemo(() => [inheritedStyles, textStyle], [inheritedStyles, textStyle]);
return <Text style={style}>{children}</Text>;
}
@@ -1438,10 +1318,13 @@ export const AssistantMessage = memo(function AssistantMessage({
disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
disableOuterSpacing ?? spacing !== "default",
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
@@ -1704,10 +1587,11 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
marginBottom={index < keyedBlocks.length - 1 ? theme.spacing[3] : 0}
>
<MemoizedMarkdownBlock
text={block}
styles={markdownStyles}
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleLinkPress}
@@ -1757,6 +1641,7 @@ export const SpeakMessage = memo(function SpeakMessage({
timestamp: _timestamp,
disableOuterSpacing,
}: SpeakMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const containerStyle = useMemo(
() => [
@@ -1769,7 +1654,7 @@ export const SpeakMessage = memo(function SpeakMessage({
return (
<View testID="speak-message" style={containerStyle}>
<View style={speakMessageStylesheet.header}>
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
<MicVocal size={14} color={theme.colors.foregroundMuted} />
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
</View>
<Text style={speakMessageStylesheet.text}>{message}</Text>
@@ -2027,6 +1912,7 @@ interface TodoListItemRowProps {
}
function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
const { theme: todoUnistylesTheme } = useUnistyles();
const badgeStyle = useMemo(
() => [
todoListCardStylesheet.radioBadge,
@@ -2043,9 +1929,7 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
return (
<View style={todoListCardStylesheet.itemRow}>
<View style={badgeStyle}>
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
{completed ? <Check size={12} color={todoUnistylesTheme.colors.primaryForeground} /> : null}
</View>
<Text style={textStyle}>{text}</Text>
</View>
@@ -2097,6 +1981,7 @@ export const TodoListCard = memo(function TodoListCard({
items,
disableOuterSpacing,
}: TodoListCardProps) {
const { theme: _unistylesTheme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
@@ -2141,7 +2026,6 @@ interface ExpandableBadgeProps {
isExpanded: boolean;
style?: StyleProp<ViewStyle>;
onToggle?: () => void;
onOpenFile?: () => void;
onDetailHoverChange?: (hovered: boolean) => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
@@ -2165,7 +2049,7 @@ function ExpandableBadgeSecondaryLabel({
onSecondaryLayout,
}: ExpandableBadgeSecondaryLabelProps) {
if (!secondaryLabel) {
return null;
return <View style={expandableBadgeStylesheet.spacer} />;
}
return (
<Text
@@ -2183,7 +2067,6 @@ interface ExpandableBadgeWebShimmerOverlayProps {
secondaryLabel?: string;
shimmerLabelTextStyle: StyleProp<TextStyle>;
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
showOpenFileButton: boolean;
}
function ExpandableBadgeWebShimmerOverlay({
@@ -2191,7 +2074,6 @@ function ExpandableBadgeWebShimmerOverlay({
secondaryLabel,
shimmerLabelTextStyle,
shimmerSecondaryTextStyle,
showOpenFileButton,
}: ExpandableBadgeWebShimmerOverlayProps) {
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
@@ -2202,15 +2084,9 @@ function ExpandableBadgeWebShimmerOverlay({
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : null}
{showOpenFileButton ? (
<View style={expandableBadgeStylesheet.openFileButton}>
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
</View>
) : null}
{!secondaryLabel && !showOpenFileButton ? (
) : (
<View style={expandableBadgeStylesheet.spacer} />
) : null}
)}
</View>
);
}
@@ -2234,11 +2110,6 @@ interface ExpandableBadgeLabelRowProps {
onLabelRowLayout: (event: LayoutChangeEvent) => void;
onLabelLayout: (event: LayoutChangeEvent) => void;
onSecondaryLayout: (event: LayoutChangeEvent) => void;
showOpenFileButton: boolean;
isOpenFileHovered: boolean;
onOpenFilePress: (event: GestureResponderEvent) => void;
onOpenFileHoverIn: () => void;
onOpenFileHoverOut: () => void;
}
function ExpandableBadgeLabelRow({
@@ -2260,11 +2131,6 @@ function ExpandableBadgeLabelRow({
onLabelRowLayout,
onLabelLayout,
onSecondaryLayout,
showOpenFileButton,
isOpenFileHovered,
onOpenFilePress,
onOpenFileHoverIn,
onOpenFileHoverOut,
}: ExpandableBadgeLabelRowProps) {
return (
<View
@@ -2284,30 +2150,12 @@ function ExpandableBadgeLabelRow({
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
onSecondaryLayout={onSecondaryLayout}
/>
{showOpenFileButton ? (
<Pressable
onPress={onOpenFilePress}
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel="Open file"
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
secondaryLabel={secondaryLabel}
shimmerLabelTextStyle={shimmerLabelTextStyle}
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
showOpenFileButton={showOpenFileButton}
/>
) : null}
{isNativeShimmer ? (
@@ -2325,44 +2173,37 @@ function ExpandableBadgeLabelRow({
);
}
function renderExpandableBadgeIcon({
function resolveExpandableBadgeIconColor({
isError,
isActive,
ThemedIcon,
theme,
}: {
isError: boolean;
isActive: boolean;
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
}): ReactNode {
if (isError) {
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
}
if (ThemedIcon) {
return (
<ThemedIcon
size={12}
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
/>
);
}
return null;
theme: ReturnType<typeof useUnistyles>["theme"];
}): string {
if (isError) return theme.colors.destructive;
if (isActive) return theme.colors.foreground;
return theme.colors.mutedForeground;
}
function renderExpandableBadgeIconSlot({
showChevron,
chevronStyle,
iconNode,
function renderExpandableBadgeIcon({
isError,
iconColor,
icon,
}: {
showChevron: boolean;
chevronStyle: StyleProp<ViewStyle>;
iconNode: ReactNode;
isError: boolean;
iconColor: string;
icon?: ComponentType<{ size?: number; color?: string }>;
}): ReactNode {
if (showChevron) {
return (
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
);
if (isError) {
return <TriangleAlertIcon size={12} color={iconColor} opacity={0.8} />;
}
return iconNode;
if (icon) {
const IconComponent = icon;
return <IconComponent size={12} color={iconColor} />;
}
return null;
}
function computeShimmerMetrics(input: {
@@ -2415,8 +2256,9 @@ function computeShimmerMetrics(input: {
function useDetailWheelPropagationBlocker(input: {
detailWrapperRef: React.RefObject<View | null>;
enabled: boolean;
wheelInvestigationComponentId: string;
}): void {
const { detailWrapperRef, enabled } = input;
const { detailWrapperRef, enabled, wheelInvestigationComponentId } = input;
useEffect(() => {
if (!enabled) {
return;
@@ -2430,11 +2272,13 @@ function useDetailWheelPropagationBlocker(input: {
event.stopPropagation();
}
};
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelAttach");
node.addEventListener("wheel", stopWheelPropagation, { passive: true });
return () => {
markScrollInvestigationEvent(wheelInvestigationComponentId, "wheelDetach");
node.removeEventListener("wheel", stopWheelPropagation);
};
}, [detailWrapperRef, enabled]);
}, [detailWrapperRef, enabled, wheelInvestigationComponentId]);
}
const SHIMMER_GRADIENT =
@@ -2471,7 +2315,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
icon,
isExpanded,
onToggle,
onOpenFile,
onDetailHoverChange,
renderDetails,
isLoading = false,
@@ -2480,15 +2323,15 @@ const ExpandableBadge = memo(function ExpandableBadge({
disableOuterSpacing,
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
const [isIconHovered, setIsIconHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => {
@@ -2502,17 +2345,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
() => onDetailHoverChange?.(false),
[onDetailHoverChange],
);
const handleOpenFilePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation?.();
onOpenFile?.();
},
[onOpenFile],
);
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
const handleIconHoverIn = useCallback(() => setIsIconHovered(true), []);
const handleIconHoverOut = useCallback(() => setIsIconHovered(false), []);
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
@@ -2592,6 +2424,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
useDetailWheelPropagationBlocker({
detailWrapperRef,
enabled: !isNative && isExpanded && hasDetailContent,
wheelInvestigationComponentId,
});
const shimmerLabelStyle = useMemo(
@@ -2706,17 +2539,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
[isExpanded],
);
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
const iconSlotNode = renderExpandableBadgeIconSlot({
showChevron: isInteractive && isHovered && !isIconHovered,
chevronStyle,
iconNode,
});
const iconColor = resolveExpandableBadgeIconColor({ isError, isActive, theme });
const iconNode = renderExpandableBadgeIcon({ isError, iconColor, icon });
const pressHandlers = isInteractive
? {
onPress: onToggle,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onPressIn: handlePressIn,
onPressOut: handlePressOut,
accessibilityRole: "button" as const,
@@ -2724,12 +2554,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
: {};
return (
<View
style={containerStyle}
testID={testID}
onPointerEnter={isWeb ? handleHoverIn : undefined}
onPointerLeave={isWeb ? handleHoverOut : undefined}
>
<View style={containerStyle} testID={testID}>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
@@ -2737,13 +2562,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View
style={expandableBadgeStylesheet.iconBadge}
onPointerEnter={isWeb ? handleIconHoverIn : undefined}
onPointerLeave={isWeb ? handleIconHoverOut : undefined}
>
{iconSlotNode}
</View>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<ExpandableBadgeLabelRow
label={label}
labelStyle={labelStyle}
@@ -2763,12 +2582,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
onLabelRowLayout={handleLabelRowLayout}
onLabelLayout={handleLabelLayout}
onSecondaryLayout={handleSecondaryLayout}
showOpenFileButton={Boolean(onOpenFile && isHovered)}
isOpenFileHovered={isOpenFileHovered}
onOpenFilePress={handleOpenFilePress}
onOpenFileHoverIn={handleOpenFileHoverIn}
onOpenFileHoverOut={handleOpenFileHoverOut}
/>
{isInteractive && isHovered ? (
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
{detailContent ? (
@@ -2797,7 +2614,6 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.testID !== next.testID) return false;
if (previous.onToggle !== next.onToggle) return false;
if (previous.onOpenFile !== next.onOpenFile) return false;
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
if (previous.renderDetails !== next.renderDetails) return false;
return true;
@@ -2816,7 +2632,6 @@ interface ToolCallProps {
disableOuterSpacing?: boolean;
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
}
export const ToolCall = memo(function ToolCall({
@@ -2832,7 +2647,6 @@ export const ToolCall = memo(function ToolCall({
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -2891,17 +2705,6 @@ export const ToolCall = memo(function ToolCall({
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const extractedFilePath = useMemo(
() => extractToolCallFilePath(effectiveDetail),
[effectiveDetail],
);
const handleOpenFile = useMemo(() => {
if (!extractedFilePath || !onOpenFilePath) {
return undefined;
}
return () => onOpenFilePath(extractedFilePath);
}, [extractedFilePath, onOpenFilePath]);
const handleToggle = useCallback(() => {
if (isMobile) {
openToolCall({
@@ -2971,7 +2774,6 @@ export const ToolCall = memo(function ToolCall({
<PlanCard
title="Plan"
text={effectiveDetail.text}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}
/>
);
@@ -2985,7 +2787,6 @@ export const ToolCall = memo(function ToolCall({
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
@@ -3007,6 +2808,5 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.metadata !== next.metadata) return false;
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
return true;
}
+1 -3
View File
@@ -199,14 +199,12 @@ export function PlanCard({
text,
footer,
disableOuterSpacing = false,
testID,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
@@ -233,7 +231,7 @@ export function PlanCard({
);
return (
<View testID={testID} style={containerStyle}>
<View style={containerStyle}>
<Text style={titleStyle}>{title}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>
@@ -1,4 +1,4 @@
import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
@@ -10,19 +10,14 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { ProviderProfileModel } from "@server/server/agent/provider-launch-config";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -31,173 +26,23 @@ interface ProviderDiagnosticSheetProps {
serverId: string;
}
function ModelRow({ model }: { model: AgentModelDefinition }) {
function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: boolean }) {
const rowStyle = useMemo(
() => [sheetStyles.modelRow, !isFirst && sheetStyles.modelRowBorder],
[isFirst],
);
return (
<View style={MODEL_ROW_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
<View style={rowStyle}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
);
}
function CustomModelRow(props: {
model: ProviderProfileModel;
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { model, deleting, onDelete } = props;
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
const deleteButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
deleting ? sheetStyles.disabled : null,
],
[deleting],
);
return (
<View style={MODEL_ROW_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
<Pressable
onPress={handleDelete}
disabled={deleting}
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
</View>
);
}
function CustomModelsSection(props: {
provider: string;
serverId: string;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { provider, serverId, refresh } = props;
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
const providerConfig = config?.providers?.[provider];
const additionalModels = useMemo(
() => providerConfig?.additionalModels ?? [],
[providerConfig?.additionalModels],
);
const trimmedInput = input.trim();
const canAdd =
trimmedInput.length > 0 && !additionalModels.some((model) => model.id === trimmedInput);
const patchAdditionalModels = useCallback(
async (nextModels: ProviderProfileModel[]) => {
await patchConfig({
providers: {
[provider]: {
additionalModels: nextModels,
},
},
});
await refresh([provider as AgentProvider]);
},
[patchConfig, provider, refresh],
);
const handleAdd = useCallback(() => {
if (!canAdd) {
return;
}
setError(null);
setSaving(true);
void patchAdditionalModels([
...additionalModels,
{
id: trimmedInput,
label: trimmedInput,
},
])
.then(() => setInput(""))
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
})
.finally(() => setSaving(false));
}, [additionalModels, canAdd, patchAdditionalModels, trimmedInput]);
const handleDelete = useCallback(
(modelId: string) => {
setError(null);
setDeletingModelId(modelId);
void patchAdditionalModels(additionalModels.filter((model) => model.id !== modelId))
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to delete model");
})
.finally(() => {
setDeletingModelId((current) => (current === modelId ? null : current));
});
},
[additionalModels, patchAdditionalModels],
);
return (
<SettingsSection title="Custom models">
<View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}>
<AdaptiveTextInput
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder="Model ID"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
// @ts-expect-error - outlineStyle is web-only
style={DIAGNOSTIC_INLINE_INPUT_STYLE}
/>
<Button
variant="default"
size="sm"
onPress={handleAdd}
disabled={!canAdd || saving}
accessibilityLabel="Add model"
>
{saving ? "Adding…" : "Add"}
</Button>
</View>
{additionalModels.map((model) => (
<CustomModelRow
key={model.id}
model={model}
deleting={deletingModelId === model.id}
onDelete={handleDelete}
/>
))}
</View>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
</SettingsSection>
);
}
function DiagnosticCodeBlock(props: {
loading: boolean;
diagnostic: string | null;
@@ -228,7 +73,7 @@ function DiagnosticCodeBlock(props: {
}
return (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
);
}
@@ -265,6 +110,7 @@ export function ProviderDiagnosticSheet({
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
// clockTick is referenced so the label recomputes each timer tick.
void clockTick;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
}, [providerEntry?.fetchedAt, clockTick]);
@@ -351,27 +197,10 @@ export function ProviderDiagnosticSheet({
}
}, [visible, fetchDiagnostic]);
const modelsTrailing = useMemo(() => {
if (models.length === 0 && !fetchedAtLabel) return undefined;
return (
<View style={sheetStyles.modelsTrailing}>
{models.length > 0 ? (
<Text style={settingsStyles.sectionHeaderTitle}>{models.length}</Text>
) : null}
{models.length > 0 && fetchedAtLabel ? (
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
) : null}
{fetchedAtLabel ? (
<Text style={settingsStyles.sectionHeaderTitle}>Updated {fetchedAtLabel}</Text>
) : null}
</View>
);
}, [models.length, fetchedAtLabel]);
function renderModelsBody() {
if (models.length === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyRow}>
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models…</Text>
</View>
@@ -379,7 +208,7 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyRow}>
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
@@ -387,21 +216,21 @@ export function ProviderDiagnosticSheet({
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyRow}>
<Text style={sheetStyles.mutedText}>No models detected</Text>
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyRow}>
<View style={sheetStyles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition) => (
<ModelRow key={model.id} model={model} />
return filteredModels.map((model: AgentModelDefinition, index) => (
<ModelRow key={model.id} model={model} isFirst={index === 0} />
));
}
@@ -411,28 +240,36 @@ export function ProviderDiagnosticSheet({
visible={visible}
onClose={onClose}
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
scrollable={false}
headerActions={headerActions}
>
<SettingsSection title="Diagnostic">
<View style={settingsStyles.card}>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
<DiagnosticCodeBlock
loading={loading}
diagnostic={diagnostic}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
</View>
</SettingsSection>
</View>
<CustomModelsSection provider={provider} serverId={serverId} refresh={refresh} />
<View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Models</Text>
{modelsTrailing}
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
</View>
<View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
@@ -444,37 +281,33 @@ export function ProviderDiagnosticSheet({
style={DIAGNOSTIC_SEARCH_INPUT_STYLE}
/>
</View>
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
</View>
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
section: {
gap: theme.spacing[2],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
mutedText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
monoHint: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginTop: theme.spacing[1],
},
errorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
marginTop: theme.spacing[2],
marginLeft: theme.spacing[1],
},
iconButton: {
width: 30,
height: 30,
@@ -488,24 +321,20 @@ const sheetStyles = StyleSheet.create((theme) => ({
disabled: {
opacity: 0.5,
},
inlineRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inlineInput: {
flex: 1,
minWidth: 0,
paddingVertical: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
},
codeScroll: {
maxHeight: 200,
maxHeight: 180,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
},
codeText: {
fontFamily: Fonts.mono,
@@ -515,44 +344,78 @@ const sheetStyles = StyleSheet.create((theme) => ({
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
modelsSection: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
modelsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
modelsTrailing: {
modelsHeaderMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
modelsScroll: {
maxHeight: 360,
metaDot: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
modelsScrollContent: {
paddingBottom: 0,
countText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyRow: {
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
searchContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
modelsScroll: {
flex: 1,
minHeight: 0,
},
modelsScrollContent: {
paddingBottom: theme.spacing[2],
},
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
modelId: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
marginTop: 2,
},
emptyState: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
}));
const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }];
const DIAGNOSTIC_INLINE_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }];
const MODEL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const INLINE_ROW_STYLE = [settingsStyles.row, sheetStyles.inlineRow];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }];
@@ -1,67 +0,0 @@
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getIsElectronRuntime } from "@/constants/layout";
import { listenToDesktopEvent } from "@/desktop/electron/events";
export function QuittingOverlay() {
const { theme } = useUnistyles();
const [quitting, setQuitting] = useState(false);
useEffect(() => {
if (!getIsElectronRuntime()) return;
let cancelled = false;
let unlisten: (() => void) | null = null;
void listenToDesktopEvent("quitting", () => {
if (!cancelled) setQuitting(true);
}).then((fn) => {
if (cancelled) {
fn();
} else {
unlisten = fn;
}
return undefined;
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
if (!quitting) return null;
return (
<View style={styles.overlay}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.title}>Quitting Paseo…</Text>
<Text style={styles.detail}>Stopping the local daemon.</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
zIndex: 9999,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -37,9 +37,9 @@ import {
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn().mockResolvedValue(null),
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
@@ -83,7 +83,6 @@ function workspace(input: {
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -9,7 +9,6 @@ import {
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
@@ -25,8 +24,7 @@ import {
} from "react";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
@@ -41,7 +39,6 @@ import {
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
@@ -56,7 +53,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import {
buildHostNewWorkspaceRoute,
buildProjectSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
@@ -87,8 +83,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -128,47 +123,17 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
function getPrIconUniMapping(state: PrHint["state"]) {
function getWorkspacePrIconColor(
theme: ReturnType<typeof useUnistyles>["theme"],
state: PrHint["state"],
) {
switch (state) {
case "merged":
return purpleColorMapping;
return theme.colors.palette.purple[500];
case "open":
return greenColorMapping;
return theme.colors.palette.green[500];
case "closed":
return redColorMapping;
return theme.colors.palette.red[500];
}
}
@@ -256,7 +221,10 @@ function useSidebarWorkspaceEntry(
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -273,8 +241,10 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const prBadgeTextStyle = useMemo(
() => [prBadgeStyles.text, { color: activeColor }],
[activeColor],
);
return (
<Pressable
@@ -288,11 +258,11 @@ export function PrBadge({ hint }: { hint: PrHint }) {
style={prBadgePressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
<ExternalLink size={12} color={activeColor} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
<GitPullRequest size={12} color={iconColor} />
)}
<Text style={textStyle} numberOfLines={1}>
<Text style={prBadgeTextStyle} numberOfLines={1}>
#{hint.number}
</Text>
</Pressable>
@@ -330,16 +300,15 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
textHovered: {
color: theme.colors.foreground,
},
}));
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
const { theme } = useUnistyles();
const color = theme.colors.palette.red[500];
const textStyle = useMemo(() => [checksBadgeStyles.text, { color }], [color]);
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
@@ -349,8 +318,8 @@ function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | n
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{label}</Text>
<GitHubIcon size={10} color={color} />
<Text style={textStyle}>{label}</Text>
</View>
);
}
@@ -365,7 +334,6 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
@@ -378,12 +346,13 @@ function WorkspaceStatusIndicator({
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const { theme } = useUnistyles();
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
@@ -391,7 +360,14 @@ function WorkspaceStatusIndicator({
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -399,18 +375,18 @@ function WorkspaceStatusIndicator({
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
let KindIcon: typeof Monitor | typeof FolderGit2 | null;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = null;
if (!KindIcon) return null;
const dotColorStyle = getStatusDotColorStyle(bucket);
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -421,10 +397,11 @@ function WorkspaceStatusIndicator({
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
dotColor={dotColor}
borderColor={theme.colors.surface0}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -434,26 +411,29 @@ function WorkspaceStatusIndicator({
}
function StatusDotOverlay({
dotColorStyle,
dotColor,
borderColor,
size,
offset,
}: {
dotColorStyle: ViewStyle;
dotColor: string;
borderColor: string;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
backgroundColor: dotColor,
borderColor,
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, size, offset],
[dotColor, borderColor, size, offset],
);
return <View style={overlayStyle} />;
}
@@ -473,6 +453,7 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
@@ -505,6 +486,7 @@ function ProjectLeadingVisual({
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
theme={theme}
/>
);
}
@@ -519,6 +501,7 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup,
onRemoveProject,
removeProjectStatus,
theme,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -529,6 +512,7 @@ function ProjectRowTrailingActions({
onBeginWorkspaceSetup: () => void;
onRemoveProject?: () => void;
removeProjectStatus: "idle" | "pending" | "success";
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
return (
@@ -549,6 +533,7 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
theme={theme}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -558,34 +543,30 @@ function ProjectRowTrailingActions({
);
}
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function ProjectKebabMenu({
projectKey,
theme,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
}) {
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
const removeProjectLeadingIcon = useMemo(
() => <Trash2 size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -595,21 +576,12 @@ function ProjectKebabMenu({
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${projectKey}`}
>
{renderKebabTriggerIcon}
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{canOpenProjectSettings ? (
<DropdownMenuItem
testID={`sidebar-project-menu-open-settings-${projectKey}`}
leading={settingsLeadingIcon}
onSelect={handleOpenProjectSettings}
>
Open project settings
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
leading={removeProjectLeadingIcon}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
@@ -630,6 +602,7 @@ function WorkspaceRowRightGroup({
isCreating,
showShortcutBadge,
shortcutNumber,
theme,
archiveLabel,
archiveStatus,
archivePendingLabel,
@@ -646,6 +619,7 @@ function WorkspaceRowRightGroup({
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
theme: ReturnType<typeof useUnistyles>["theme"];
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
@@ -660,9 +634,9 @@ function WorkspaceRowRightGroup({
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
<Globe size={12} color={theme.colors.palette.blue[500]} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
<SquareTerminal size={12} color={theme.colors.palette.blue[500]} />
)}
</View>
) : null}
@@ -670,6 +644,7 @@ function WorkspaceRowRightGroup({
{showKebab && onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
theme={theme}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onArchive={onArchive}
@@ -694,8 +669,21 @@ function WorkspaceRowRightGroup({
);
}
function WorkspaceKebabTriggerIcon({
hovered,
foreground,
foregroundMuted,
}: {
hovered: boolean;
foreground: string;
foregroundMuted: string;
}) {
return <MoreVertical size={14} color={hovered ? foreground : foregroundMuted} />;
}
function WorkspaceKebabMenu({
workspaceKey,
theme,
onCopyPath,
onCopyBranchName,
onArchive,
@@ -705,6 +693,7 @@ function WorkspaceKebabMenu({
archiveShortcutKeys,
}: {
workspaceKey: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onArchive: () => void;
@@ -713,10 +702,28 @@ function WorkspaceKebabMenu({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const copyLeadingIcon = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveLeadingIcon = useMemo(
() => <Archive size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
const renderTriggerIcon = useCallback(
({ hovered }: { hovered?: boolean }) => (
<WorkspaceKebabTriggerIcon
hovered={Boolean(hovered)}
foreground={theme.colors.foreground}
foregroundMuted={theme.colors.foregroundMuted}
/>
),
[theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -726,7 +733,7 @@ function WorkspaceKebabMenu({
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
{renderTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
@@ -786,17 +793,19 @@ function ProjectLeadingVisualStatus({
isArchiving,
shouldShowSyncedLoader,
activeWorkspace,
theme,
}: {
iconDataUri: string | null;
placeholderInitial: string;
isArchiving: boolean;
shouldShowSyncedLoader: boolean;
activeWorkspace: SidebarWorkspaceEntry;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
@@ -804,7 +813,14 @@ function ProjectLeadingVisualStatus({
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
<SyncedLoader
size={11}
color={
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500]
}
/>
</View>
);
}
@@ -812,12 +828,16 @@ function ProjectLeadingVisualStatus({
if (activeWorkspace.statusBucket === "needs_input") {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket);
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
@@ -829,9 +849,10 @@ function ProjectLeadingVisualStatus({
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
{dotColorStyle ? (
{dotColor ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
dotColor={dotColor}
borderColor={theme.colors.surface0}
size={statusDotSize}
offset={statusDotOffset}
/>
@@ -865,6 +886,7 @@ function NewWorktreeButton({
testID: string;
showShortcutHint?: boolean;
}) {
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree");
const pressableStyle = useCallback(
@@ -898,12 +920,12 @@ function NewWorktreeButton({
>
{({ hovered, pressed }) =>
loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<ThemedFolderPlus
<FolderPlus
size={15}
uniProps={
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
@@ -1163,6 +1185,7 @@ function ProjectHeaderRow({
removeProjectStatus = "idle",
dragHandleProps,
}: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const handleBeginWorkspaceSetup = useCallback(() => {
@@ -1232,6 +1255,7 @@ function ProjectHeaderRow({
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
theme={theme}
/>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
@@ -1307,6 +1331,7 @@ function WorkspaceRowInner({
onCopyPath,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
@@ -1402,6 +1427,7 @@ function WorkspaceRowInner({
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
theme={theme}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
@@ -1462,7 +1488,7 @@ function WorkspaceRowWithMenu({
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -2724,7 +2750,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
projectActionTooltipShortcut: {},
projectActionTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
@@ -2863,35 +2892,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,
},
statusDotFailed: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.surface0,
},
statusDotRunning: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.surface0,
},
statusDotAttention: {
backgroundColor: theme.colors.palette.green[500],
borderColor: theme.colors.surface0,
},
}));
function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null {
switch (bucket) {
case "needs_input":
return styles.statusDotNeedsInput;
case "failed":
return styles.statusDotFailed;
case "running":
return styles.statusDotRunning;
case "attention":
return styles.statusDotAttention;
case "done":
return null;
}
}
@@ -91,8 +91,6 @@ interface SplitContainerProps {
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
onCreateBrowserTab: (input: { paneId?: string }) => void;
showCreateBrowserTab?: boolean;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -128,7 +126,7 @@ interface SplitPaneDropData {
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string | null;
focusedPaneId: string;
activeDragTabId: string | null;
showDropZones: boolean;
dropPreview: SplitDropZoneHover | null;
@@ -160,7 +158,6 @@ interface MountedTabSlotProps {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string;
onFocusPane: (paneId: string) => void;
buildPaneContentModel: (input: {
paneId: string;
tab: WorkspaceTabDescriptor;
@@ -173,7 +170,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
isWorkspaceFocused,
isPaneFocused,
paneId,
onFocusPane,
buildPaneContentModel,
}: MountedTabSlotProps) {
const content = useMemo(
@@ -189,9 +185,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
[isVisible],
);
const handleFocusPane = useCallback(() => {
onFocusPane(paneId);
}, [onFocusPane, paneId]);
return (
<View style={wrapperStyle}>
@@ -199,7 +192,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
);
@@ -347,8 +339,6 @@ export function SplitContainer({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -380,7 +370,7 @@ export function SplitContainer({
if (!focusModeEnabled) {
return layout.root;
}
const focusedPane = layout.focusedPaneId ? panesById.get(layout.focusedPaneId) : null;
const focusedPane = panesById.get(layout.focusedPaneId);
if (!focusedPane) {
return layout.root;
}
@@ -567,8 +557,6 @@ export function SplitContainer({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -706,8 +694,6 @@ function SplitNodeView({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -758,8 +744,6 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -803,8 +787,6 @@ function SplitNodeView({
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -854,8 +836,6 @@ function SplitPaneView({
onCloseOtherTabs,
onCreateDraftTab,
onCreateTerminalTab,
onCreateBrowserTab,
showCreateBrowserTab,
buildPaneContentModel,
onFocusPane,
onSplitPane: _onSplitPane,
@@ -997,8 +977,6 @@ function SplitPaneView({
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
@@ -1026,7 +1004,6 @@ function SplitPaneView({
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
@@ -9,9 +9,7 @@ import {
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
@@ -31,7 +29,6 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -48,9 +45,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
@@ -71,7 +65,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyRows = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -162,13 +155,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
});
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
@@ -241,18 +227,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
@@ -333,17 +307,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
const historyFooterContent = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<View testID="load-older-history-spinner">
<ActivityIndicator size="small" />
</View>
);
}, [isLoadingOlderHistory]);
return (
<FlatList
ref={flatListRef}
@@ -353,7 +316,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
@@ -9,24 +9,6 @@ import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => false,
matches: false,
media: "",
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}),
});
});
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
function userMessage(index: number): StreamItem {
return {
kind: "user_message",
@@ -133,9 +115,6 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -148,63 +127,4 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
act(() => {
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
});
@@ -8,7 +8,6 @@ import React, {
useRef,
useState,
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
@@ -24,17 +23,18 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return Number(performance.now().toFixed(3));
}
return null;
}
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
@@ -102,9 +102,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
isMobileBreakpoint,
} = props;
@@ -123,13 +120,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
};
const followOutputRef = useRef(followOutput);
const lastKnownScrollTopRef = useRef(0);
const lastLoggedMetricsRef = useRef<{
scrollTop: number;
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
} | null>(null);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
const lastTouchClientYRef = useRef<number | null>(null);
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
@@ -165,12 +168,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
logWebStickyBottom("virtualizer_item_size_change", {
agentId: props.agentId,
delta: _delta,
itemIndex: _item.index,
itemStart: _item.start,
itemSize: _item.size,
viewportHeight,
scrollOffset,
totalSize: instance.getTotalSize(),
remainingDistance,
});
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
};
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
};
}, [rowVirtualizer]);
}, [rowVirtualizer, props.agentId]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
@@ -228,11 +242,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return;
}
logWebStickyBottom("viewport_scroll_to_bottom", {
agentId: props.agentId,
behavior,
followOutput: followOutputRef.current,
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
});
scrollElementToBottom(scrollContainer, behavior);
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
},
[onNearBottomChange],
[onNearBottomChange, props.agentId],
);
const scheduleStickToBottom = useCallback(() => {
@@ -243,6 +267,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (pendingAutoScrollFrameRef.current !== null) {
return;
}
logWebStickyBottom("viewport_schedule_stick_to_bottom", {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer?.scrollTop ?? null,
clientWidth: scrollContainer?.clientWidth ?? null,
clientHeight: scrollContainer?.clientHeight ?? null,
scrollWidth: scrollContainer?.scrollWidth ?? null,
scrollHeight: scrollContainer?.scrollHeight ?? null,
});
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null;
if (!followOutputRef.current) {
@@ -250,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
scrollMessagesToBottom("auto");
});
}, [scrollMessagesToBottom]);
}, [props.agentId, scrollMessagesToBottom]);
const forceStickToBottom = useCallback(() => {
cancelPendingStickToBottom();
@@ -265,7 +298,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
syncNearBottom(scrollContainer, onNearBottomChange);
}, [onNearBottomChange]);
const currentMetrics = {
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
};
const previousMetrics = lastLoggedMetricsRef.current;
const shouldLog =
!previousMetrics ||
previousMetrics.scrollTop !== currentMetrics.scrollTop ||
previousMetrics.clientWidth !== currentMetrics.clientWidth ||
previousMetrics.clientHeight !== currentMetrics.clientHeight ||
previousMetrics.scrollWidth !== currentMetrics.scrollWidth ||
previousMetrics.scrollHeight !== currentMetrics.scrollHeight;
if (shouldLog) {
lastLoggedMetricsRef.current = currentMetrics;
logWebStickyBottom("viewport_metrics_updated", {
agentId: props.agentId,
followOutput: followOutputRef.current,
distanceFromBottom: getScrollContainerDistanceFromBottom(scrollContainer),
...currentMetrics,
});
}
}, [onNearBottomChange, props.agentId]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
@@ -294,25 +351,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
if (
historyStartReadyRef.current &&
hasOlderHistory &&
currentScrollTop <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
logWebStickyBottom("viewport_dom_scroll", {
agentId: props.agentId,
now: getDebugNow(),
scrollTop: currentScrollTop,
clientHeight: scrollContainer.clientHeight,
scrollHeight: scrollContainer.scrollHeight,
activeElementTag:
typeof document !== "undefined"
? (document.activeElement?.tagName?.toLowerCase() ?? null)
: null,
activeElementRole:
typeof document !== "undefined"
? (document.activeElement?.getAttribute?.("aria-label") ?? null)
: null,
});
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
};
}, [props.agentId]);
updateScrollMetrics();
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -376,6 +431,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
const observer = new ResizeObserver(() => {
logWebStickyBottom("viewport_resize_observed", {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
});
updateScrollMetrics();
if (!followOutputRef.current) {
return;
@@ -389,7 +453,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [scheduleStickToBottom, updateScrollMetrics]);
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -397,6 +461,59 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return;
}
const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer);
const scrollTopDescriptor =
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), "scrollTop") ??
Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop");
scrollContainer.scrollTo = ((...args: Parameters<HTMLElement["scrollTo"]>) => {
const firstArg = args[0] as ScrollToOptions | number | undefined;
const target =
typeof firstArg === "object" && firstArg !== null
? {
top: firstArg.top ?? null,
left: firstArg.left ?? null,
behavior: firstArg.behavior ?? null,
}
: {
top: typeof args[1] === "number" ? args[1] : null,
left: typeof firstArg === "number" ? firstArg : null,
behavior: null,
};
logWebStickyBottom("viewport_scroll_to_called", {
agentId: props.agentId,
now: getDebugNow(),
currentScrollTop: scrollContainer.scrollTop,
target,
stack:
typeof Error !== "undefined"
? (new Error().stack?.split("\n").slice(1, 6).join("\n") ?? null)
: null,
});
return originalScrollTo(...args);
}) as typeof scrollContainer.scrollTo;
if (scrollTopDescriptor?.get && scrollTopDescriptor?.set) {
Object.defineProperty(scrollContainer, "scrollTop", {
configurable: true,
enumerable: scrollTopDescriptor.enumerable ?? false,
get() {
return scrollTopDescriptor.get?.call(scrollContainer);
},
set(value: number) {
logWebStickyBottom("viewport_scroll_top_set", {
agentId: props.agentId,
now: getDebugNow(),
currentScrollTop: scrollTopDescriptor.get?.call(scrollContainer) ?? null,
nextScrollTop: value,
stack:
typeof Error !== "undefined"
? (new Error().stack?.split("\n").slice(1, 6).join("\n") ?? null)
: null,
});
return scrollTopDescriptor.set?.call(scrollContainer, value);
},
});
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true;
@@ -431,6 +548,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleTouchEnd = () => {
lastTouchClientYRef.current = null;
};
const handleSelectionChange = () => {
const activeElement =
typeof document !== "undefined"
? (document.activeElement as HTMLTextAreaElement | null)
: null;
logWebStickyBottom("document_selection_changed", {
agentId: props.agentId,
now: getDebugNow(),
activeElementTag: activeElement?.tagName?.toLowerCase() ?? null,
activeElementRole: activeElement?.getAttribute?.("aria-label") ?? null,
selectionStart:
activeElement && typeof activeElement.selectionStart === "number"
? activeElement.selectionStart
: null,
selectionEnd:
activeElement && typeof activeElement.selectionEnd === "number"
? activeElement.selectionEnd
: null,
scrollTop: scrollContainer.scrollTop,
});
};
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
@@ -441,6 +579,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
if (typeof document !== "undefined") {
document.addEventListener("selectionchange", handleSelectionChange, { passive: true });
}
return () => {
scrollContainer.removeEventListener("scroll", handleDomScroll);
@@ -452,8 +593,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
scrollContainer.scrollTo = originalScrollTo;
if (scrollTopDescriptor) {
Reflect.deleteProperty(scrollContainer, "scrollTop");
}
if (typeof document !== "undefined") {
document.removeEventListener("selectionchange", handleSelectionChange);
}
};
}, [cancelPendingStickToBottom, handleDomScroll]);
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -466,6 +614,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return;
}
const scrollContainer = scrollContainerRef.current;
logWebStickyBottom("viewport_prepare_for_change", {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer?.scrollTop ?? null,
clientWidth: scrollContainer?.clientWidth ?? null,
clientHeight: scrollContainer?.clientHeight ?? null,
scrollWidth: scrollContainer?.scrollWidth ?? null,
scrollHeight: scrollContainer?.scrollHeight ?? null,
});
scheduleStickToBottom();
},
};
@@ -476,10 +634,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom();
};
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
}, [
cancelPendingStickToBottom,
forceStickToBottom,
props.agentId,
scheduleStickToBottom,
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => {
return {
const contentContainerStyle = useMemo(
(): CSSProperties => ({
display: "flex",
flexDirection: "column",
minHeight: "100%",
@@ -488,24 +652,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
paddingLeft: isMobileBreakpoint ? 8 : 16,
paddingRight: isMobileBreakpoint ? 8 : 16,
boxSizing: "border-box",
};
}, [isMobileBreakpoint]);
const scrollContainerStyle = useMemo((): CSSProperties => {
return {
}),
[isMobileBreakpoint],
);
const scrollContainerStyle = useMemo(
(): CSSProperties => ({
flex: 1,
minHeight: 0,
overflowX: "hidden",
overflowY: scrollEnabled ? "auto" : "hidden",
overscrollBehaviorY: "contain",
};
}, [scrollEnabled]);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
return {
}),
[scrollEnabled],
);
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
position: "relative",
width: "100%",
height: virtualTotalSize,
};
}, [virtualTotalSize]);
}),
[virtualTotalSize],
);
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -518,31 +685,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
return segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
));
}, [renderLiveHeadRow, segments.liveHead]);
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
<ActivityIndicator size="small" />
</div>
);
}, [isLoadingOlderHistory]);
const mountedHistoryRows = useMemo(
() =>
segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
)),
[renderHistoryMountedRow, segments.historyMounted],
);
const liveHeadRows = useMemo(
() =>
segments.liveHead.map((item, index) => (
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
)),
[renderLiveHeadRow, segments.liveHead],
);
const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -558,7 +717,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
@@ -60,9 +60,6 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
@@ -18,7 +18,6 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
@@ -128,7 +127,6 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -183,7 +181,6 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -201,8 +198,6 @@ export default function TerminalEmulator({
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme;
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
onRendererReadyChangeRef.current = onRendererReadyChange;
const mountCallbacksRef = useRef({
onInput,
onResize,
@@ -394,11 +389,9 @@ export default function TerminalEmulator({
initialSnapshot: initialSnapshotRef.current,
theme: mountedThemeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
return () => {
runtime.unmount();
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}

Some files were not shown because too many files have changed in this diff Show More