mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d321899c |
@@ -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,15 +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
|
||||
@@ -65,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
|
||||
@@ -112,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
|
||||
@@ -161,22 +140,20 @@ 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
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
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 }}
|
||||
@@ -185,7 +162,7 @@ jobs:
|
||||
|
||||
finalize-mac-manifest:
|
||||
needs: [publish-macos]
|
||||
if: ${{ needs.publish-macos.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
if: ${{ needs.publish-macos.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -193,30 +170,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package.json
|
||||
package-lock.json
|
||||
scripts
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
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: 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 manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -228,11 +188,68 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||
node scripts/merge-mac-manifest.mjs \
|
||||
"mac-manifest-arm64/${manifest_name}" \
|
||||
"mac-manifest-x64/${manifest_name}" \
|
||||
"${manifest_name}"
|
||||
|
||||
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'
|
||||
@@ -252,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
|
||||
@@ -291,27 +308,22 @@ jobs:
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
- 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
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
needs: [create-release]
|
||||
@@ -325,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
|
||||
@@ -377,94 +389,14 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
finalize-rollout:
|
||||
needs: [publish-macos, finalize-mac-manifest, publish-linux, publish-windows]
|
||||
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.finalize-mac-manifest.result == 'success' || needs.finalize-mac-manifest.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: Stamp rollout metadata
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
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
|
||||
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[@]}"
|
||||
|
||||
@@ -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"
|
||||
+2
-34
@@ -1,48 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
## 0.1.63-beta.1 - 2026-04-24
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -144,12 +144,6 @@ npm run build:daemon
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# DESIGN-SYSTEM.md
|
||||
|
||||
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` |
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -59,65 +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 at publish, 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`.
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
|
||||
### 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):**
|
||||
|
||||
```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 a concurrency group with `desktop-release.yml`'s `finalize-rollout` job keyed on the tag, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
|
||||
### Faster ramp at release time
|
||||
|
||||
For low-risk changes (doc-only, dependency bumps with no behavior change), trigger `desktop-release.yml` manually with a shorter rollout window so the build itself stamps a smaller `rolloutHours`:
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-release.yml \
|
||||
-f tag=v0.1.43 \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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-d87dMrQ2iY0sl0ddOapdEBGtnpbewowZh0DMg/smAb0=";
|
||||
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).
|
||||
|
||||
Generated
+503
-504
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.63-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -95,7 +95,6 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
|
||||
@@ -37,9 +37,6 @@ yarn-error.*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# vitest browser failure screenshots
|
||||
.vitest-screenshots/
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
|
||||
@@ -164,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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.63",
|
||||
"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",
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -28,9 +27,8 @@ import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-nativ
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
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,7 +48,6 @@ 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";
|
||||
@@ -61,7 +58,7 @@ import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
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";
|
||||
@@ -73,7 +70,6 @@ import {
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
syncNavigationActiveWorkspace,
|
||||
@@ -103,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() {
|
||||
@@ -185,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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,90 +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 storeReady =
|
||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||
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 (
|
||||
@@ -365,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 {
|
||||
@@ -465,7 +485,7 @@ function AppContainer({
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<DaemonVersionMismatchCalloutSource />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
@@ -474,7 +494,6 @@ function AppContainer({
|
||||
/>
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<QuittingOverlay />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
{/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -84,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) =>
|
||||
@@ -157,6 +156,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -271,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,
|
||||
@@ -303,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) => {
|
||||
@@ -492,7 +485,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
cwd={agent.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -507,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(
|
||||
@@ -891,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;
|
||||
}
|
||||
|
||||
@@ -925,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>
|
||||
)}
|
||||
@@ -954,6 +944,7 @@ function PermissionRequestCard({
|
||||
permission: PendingPermission;
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const { request } = permission;
|
||||
@@ -1069,6 +1060,10 @@ function PermissionRequestCard({
|
||||
[handleResponse],
|
||||
);
|
||||
|
||||
const questionTextStyle = useMemo(
|
||||
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const optionsContainerStyle = useMemo(
|
||||
() => [
|
||||
permissionStyles.optionsContainer,
|
||||
@@ -1076,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 (
|
||||
@@ -1089,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")
|
||||
@@ -1110,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}
|
||||
/>
|
||||
);
|
||||
@@ -1134,10 +1153,10 @@ function PermissionRequestCard({
|
||||
}
|
||||
|
||||
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} disableOuterSpacing />
|
||||
@@ -1283,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],
|
||||
@@ -1306,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],
|
||||
@@ -1323,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,
|
||||
@@ -1340,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;
|
||||
|
||||
+12
-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();
|
||||
+13
-19
@@ -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],
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
deleteAttachmentsMock,
|
||||
encodeImagesMock,
|
||||
openExternalUrlMock,
|
||||
markScrollInvestigationRenderMock,
|
||||
mockSessionState,
|
||||
setAgentStreamTailMock,
|
||||
setAgentStreamHeadMock,
|
||||
@@ -160,6 +161,7 @@ const {
|
||||
);
|
||||
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
|
||||
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
|
||||
const hoistedMarkScrollInvestigationRenderMock = vi.fn();
|
||||
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
|
||||
|
||||
return {
|
||||
@@ -173,6 +175,7 @@ const {
|
||||
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||
openExternalUrlMock: vi.fn(async () => {}),
|
||||
markScrollInvestigationRenderMock: hoistedMarkScrollInvestigationRenderMock,
|
||||
mockSessionState: hoistedMockSessionState,
|
||||
setAgentStreamTailMock: hoistedSetAgentStreamTailMock,
|
||||
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
|
||||
@@ -185,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 }),
|
||||
}));
|
||||
|
||||
@@ -336,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,
|
||||
@@ -584,6 +591,7 @@ beforeEach(() => {
|
||||
deleteAttachmentsMock.mockClear();
|
||||
encodeImagesMock.mockClear();
|
||||
openExternalUrlMock.mockClear();
|
||||
markScrollInvestigationRenderMock.mockClear();
|
||||
setAgentStreamTailMock.mockClear();
|
||||
setAgentStreamHeadMock.mockClear();
|
||||
setQueuedMessagesMock.mockClear();
|
||||
@@ -729,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([
|
||||
@@ -972,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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -91,8 +92,8 @@ type AttachmentListUpdater =
|
||||
|
||||
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(
|
||||
@@ -584,6 +585,7 @@ interface QueuedMessageRowProps {
|
||||
}
|
||||
|
||||
function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(item.id);
|
||||
}, [onEdit, item.id]);
|
||||
@@ -597,10 +599,10 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
</Text>
|
||||
<View style={styles.queueActions}>
|
||||
<Pressable onPress={handleEdit} style={styles.queueActionButton}>
|
||||
<ThemedPencil size={ICON_SIZE.sm} uniProps={iconForegroundMapping} />
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE}>
|
||||
<ArrowUp size={ICON_SIZE.sm} color="white" />
|
||||
<ArrowUp size={theme.iconSize.sm} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -666,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(() => {
|
||||
@@ -686,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}>
|
||||
@@ -716,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
|
||||
@@ -868,6 +872,7 @@ interface ComposerVoiceModeButtonProps {
|
||||
realtimeVoiceButtonStyle: (
|
||||
state: PressableStateCallbackType & { hovered?: boolean },
|
||||
) => (object | undefined)[];
|
||||
theme: Theme;
|
||||
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
|
||||
}
|
||||
|
||||
@@ -906,6 +911,7 @@ function ComposerVoiceModeButton({
|
||||
isConnected,
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
theme,
|
||||
voiceToggleKeys,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? (
|
||||
@@ -916,10 +922,10 @@ function ComposerVoiceModeButton({
|
||||
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}>
|
||||
@@ -971,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);
|
||||
@@ -1422,6 +1430,7 @@ export function Composer({
|
||||
isConnected={isConnected}
|
||||
isVoiceSwitching={isVoiceSwitchingValue}
|
||||
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
||||
theme={theme}
|
||||
voiceToggleKeys={voiceToggleKeys}
|
||||
cancelButton={cancelButton}
|
||||
/>
|
||||
@@ -1438,6 +1447,7 @@ export function Composer({
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitchingValue,
|
||||
realtimeVoiceButtonStyle,
|
||||
theme,
|
||||
voiceToggleKeys,
|
||||
],
|
||||
);
|
||||
@@ -1481,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();
|
||||
},
|
||||
@@ -1489,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(
|
||||
@@ -1850,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 });
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -112,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 }) => (
|
||||
@@ -195,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),
|
||||
|
||||
@@ -235,9 +235,7 @@ function DiffGutterCell({
|
||||
);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text numberOfLines={1} style={textStyle}>
|
||||
{formatDiffGutterText(lineNumber)}
|
||||
</Text>
|
||||
<Text style={textStyle}>{formatDiffGutterText(lineNumber)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -352,9 +350,7 @@ function DiffLineView({
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
||||
{formatDiffGutterText(lineNumber)}
|
||||
</Text>
|
||||
<Text style={gutterTextStyle}>{formatDiffGutterText(lineNumber)}</Text>
|
||||
</View>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
@@ -407,9 +403,7 @@ function SplitDiffLine({
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
||||
{formatDiffGutterText(line?.lineNumber ?? null)}
|
||||
</Text>
|
||||
<Text style={gutterTextStyle}>{formatDiffGutterText(line?.lineNumber ?? null)}</Text>
|
||||
</View>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
@@ -630,7 +624,6 @@ function DiffFileBody({
|
||||
}) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [bodyWidth, setBodyWidth] = useState(0);
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
@@ -667,7 +660,7 @@ function DiffFileBody({
|
||||
hunk.newStart + hunk.newCount,
|
||||
);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo, theme.fontSize.xs);
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
|
||||
if (layout === "split") {
|
||||
const rows = buildSplitDiffRows(file);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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"]>;
|
||||
@@ -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");
|
||||
@@ -1529,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,
|
||||
@@ -1538,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));
|
||||
@@ -1569,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;
|
||||
@@ -1579,6 +1774,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
if (!shouldHandleDesktopSubmit) return;
|
||||
handleDesktopKeyPressImpl(event, {
|
||||
investigationComponentId,
|
||||
onKeyPressCallback,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
@@ -1626,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(() => {
|
||||
@@ -1689,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(
|
||||
@@ -1701,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 (
|
||||
@@ -1712,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}
|
||||
@@ -1945,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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -67,7 +64,6 @@ import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
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,
|
||||
@@ -79,6 +75,7 @@ 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 {
|
||||
getAssistantImageMetadata,
|
||||
@@ -154,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% {
|
||||
@@ -318,7 +295,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
userSelect: isWeb ? "text" : "auto",
|
||||
},
|
||||
content: {
|
||||
alignItems: "flex-end",
|
||||
@@ -402,6 +378,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [messageHovered, setMessageHovered] = useState(false);
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
@@ -418,12 +395,12 @@ 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(
|
||||
() => [
|
||||
@@ -493,7 +470,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
userSelect: isWeb ? "text" : "auto",
|
||||
},
|
||||
containerCompactTop: {
|
||||
paddingTop: 0,
|
||||
@@ -649,6 +625,7 @@ function AssistantMarkdownImage({
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
@@ -656,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({
|
||||
@@ -1017,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,
|
||||
@@ -1031,6 +1007,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
color: "transparent",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
flexShrink: 1,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
@@ -1039,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" }],
|
||||
},
|
||||
@@ -1247,6 +1214,7 @@ function AssistantMessageBlockContainer({
|
||||
|
||||
interface MemoizedMarkdownBlockProps {
|
||||
text: string;
|
||||
styles: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
parser: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
@@ -1254,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}
|
||||
@@ -1268,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>;
|
||||
}
|
||||
|
||||
@@ -1354,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);
|
||||
@@ -1620,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}
|
||||
@@ -1673,6 +1641,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
timestamp: _timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -1685,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>
|
||||
@@ -1943,6 +1912,7 @@ interface TodoListItemRowProps {
|
||||
}
|
||||
|
||||
function TodoListItemRow({ text, completed }: TodoListItemRowProps) {
|
||||
const { theme: todoUnistylesTheme } = useUnistyles();
|
||||
const badgeStyle = useMemo(
|
||||
() => [
|
||||
todoListCardStylesheet.radioBadge,
|
||||
@@ -1959,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>
|
||||
@@ -2013,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]);
|
||||
@@ -2057,7 +2026,6 @@ interface ExpandableBadgeProps {
|
||||
isExpanded: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onToggle?: () => void;
|
||||
onOpenFile?: () => void;
|
||||
onDetailHoverChange?: (hovered: boolean) => void;
|
||||
renderDetails?: () => ReactNode;
|
||||
isLoading?: boolean;
|
||||
@@ -2081,7 +2049,7 @@ function ExpandableBadgeSecondaryLabel({
|
||||
onSecondaryLayout,
|
||||
}: ExpandableBadgeSecondaryLabelProps) {
|
||||
if (!secondaryLabel) {
|
||||
return null;
|
||||
return <View style={expandableBadgeStylesheet.spacer} />;
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
@@ -2099,7 +2067,6 @@ interface ExpandableBadgeWebShimmerOverlayProps {
|
||||
secondaryLabel?: string;
|
||||
shimmerLabelTextStyle: StyleProp<TextStyle>;
|
||||
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
|
||||
showOpenFileButton: boolean;
|
||||
}
|
||||
|
||||
function ExpandableBadgeWebShimmerOverlay({
|
||||
@@ -2107,7 +2074,6 @@ function ExpandableBadgeWebShimmerOverlay({
|
||||
secondaryLabel,
|
||||
shimmerLabelTextStyle,
|
||||
shimmerSecondaryTextStyle,
|
||||
showOpenFileButton,
|
||||
}: ExpandableBadgeWebShimmerOverlayProps) {
|
||||
return (
|
||||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||
@@ -2118,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>
|
||||
);
|
||||
}
|
||||
@@ -2150,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({
|
||||
@@ -2176,11 +2131,6 @@ function ExpandableBadgeLabelRow({
|
||||
onLabelRowLayout,
|
||||
onLabelLayout,
|
||||
onSecondaryLayout,
|
||||
showOpenFileButton,
|
||||
isOpenFileHovered,
|
||||
onOpenFilePress,
|
||||
onOpenFileHoverIn,
|
||||
onOpenFileHoverOut,
|
||||
}: ExpandableBadgeLabelRowProps) {
|
||||
return (
|
||||
<View
|
||||
@@ -2200,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 ? (
|
||||
@@ -2241,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: {
|
||||
@@ -2331,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;
|
||||
@@ -2346,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 =
|
||||
@@ -2387,7 +2315,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
icon,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onOpenFile,
|
||||
onDetailHoverChange,
|
||||
renderDetails,
|
||||
isLoading = false,
|
||||
@@ -2396,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(() => {
|
||||
@@ -2418,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)}`,
|
||||
@@ -2508,6 +2424,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
useDetailWheelPropagationBlocker({
|
||||
detailWrapperRef,
|
||||
enabled: !isNative && isExpanded && hasDetailContent,
|
||||
wheelInvestigationComponentId,
|
||||
});
|
||||
|
||||
const shimmerLabelStyle = useMemo(
|
||||
@@ -2622,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,
|
||||
@@ -2640,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}
|
||||
@@ -2653,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}
|
||||
@@ -2679,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 ? (
|
||||
@@ -2713,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;
|
||||
@@ -2732,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({
|
||||
@@ -2748,7 +2647,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
disableOuterSpacing,
|
||||
onInlineDetailsHoverChange,
|
||||
onInlineDetailsExpandedChange,
|
||||
onOpenFilePath,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -2807,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({
|
||||
@@ -2900,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"}
|
||||
@@ -2922,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,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,
|
||||
},
|
||||
}));
|
||||
@@ -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}
|
||||
@@ -2866,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
import { useWebElementScrollbar } from "./use-web-scrollbar";
|
||||
|
||||
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">,
|
||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
@@ -109,6 +120,13 @@ 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);
|
||||
@@ -150,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();
|
||||
|
||||
@@ -213,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(() => {
|
||||
@@ -228,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) {
|
||||
@@ -235,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scrollMessagesToBottom("auto");
|
||||
});
|
||||
}, [scrollMessagesToBottom]);
|
||||
}, [props.agentId, scrollMessagesToBottom]);
|
||||
|
||||
const forceStickToBottom = useCallback(() => {
|
||||
cancelPendingStickToBottom();
|
||||
@@ -250,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;
|
||||
@@ -279,8 +351,23 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
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,
|
||||
});
|
||||
updateScrollMetrics();
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics]);
|
||||
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -344,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;
|
||||
@@ -357,7 +453,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scheduleStickToBottom, updateScrollMetrics]);
|
||||
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -365,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;
|
||||
@@ -399,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 });
|
||||
@@ -409,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);
|
||||
@@ -420,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 = {
|
||||
@@ -434,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();
|
||||
},
|
||||
};
|
||||
@@ -444,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%",
|
||||
@@ -456,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",
|
||||
@@ -486,21 +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 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 &&
|
||||
|
||||
@@ -164,10 +164,7 @@ export function TerminalPane({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
// Keep the latest measured size for whichever client currently owns the pane,
|
||||
// but only dedupe resizes that this specific client has already pushed.
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const workspaceTerminalSession = useMemo(
|
||||
() => getWorkspaceTerminalSession({ scopeKey }),
|
||||
@@ -213,7 +210,7 @@ export function TerminalPane({
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
lastReportedSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
}
|
||||
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||
@@ -299,8 +296,7 @@ export function TerminalPane({
|
||||
}, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]);
|
||||
|
||||
useEffect(() => {
|
||||
measuredTerminalSizeRef.current = null;
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
lastReportedSizeRef.current = null;
|
||||
}, [scopeKey]);
|
||||
|
||||
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
||||
@@ -320,7 +316,7 @@ export function TerminalPane({
|
||||
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => measuredTerminalSizeRef.current,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
onOutput: ({ terminalId: outputTerminalId, text }) => {
|
||||
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
|
||||
return;
|
||||
@@ -536,25 +532,24 @@ export function TerminalPane({
|
||||
|
||||
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (rows <= 0 || cols <= 0) {
|
||||
if (
|
||||
!client ||
|
||||
!terminalId ||
|
||||
!isPaneFocused ||
|
||||
!isWorkspaceFocused ||
|
||||
!isAppVisible ||
|
||||
rows <= 0 ||
|
||||
cols <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
||||
measuredTerminalSizeRef.current = nextSize;
|
||||
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) {
|
||||
const previous = lastReportedSizeRef.current;
|
||||
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
|
||||
return;
|
||||
}
|
||||
const previousSent = lastSentTerminalSizeRef.current;
|
||||
if (
|
||||
previousSent &&
|
||||
previousSent.rows === normalizedRows &&
|
||||
previousSent.cols === normalizedCols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSentTerminalSizeRef.current = nextSize;
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
|
||||
@@ -382,6 +382,15 @@ interface SearchDetail {
|
||||
|
||||
function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
if (detail.query) {
|
||||
out.push(
|
||||
<View key="search-query" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.query}
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
}
|
||||
if (detail.content) {
|
||||
out.push(
|
||||
<View key="search-content" style={styles.section}>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "lucide-react-native";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface AlertProps {
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
variant?: AlertVariant;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const VARIANT_ICON: Record<Exclude<AlertVariant, "default">, LucideIcon> = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
};
|
||||
|
||||
export function Alert({
|
||||
title,
|
||||
description,
|
||||
variant = "default",
|
||||
icon,
|
||||
children,
|
||||
testID,
|
||||
}: AlertProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accentColor = resolveAccentColor(variant, theme);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, accentColor ? { borderColor: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, accentColor ? { color: accentColor } : null],
|
||||
[accentColor],
|
||||
);
|
||||
|
||||
const resolvedIcon = useMemo(() => {
|
||||
if (icon !== undefined) return icon;
|
||||
if (variant === "default") return null;
|
||||
const Icon = VARIANT_ICON[variant];
|
||||
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
||||
}, [icon, variant, theme, accentColor]);
|
||||
|
||||
const hasDescription = description != null && description !== "";
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
||||
<View style={styles.body}>
|
||||
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
||||
{hasDescription && typeof description === "string" ? (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
) : null}
|
||||
{hasDescription && typeof description !== "string" ? (
|
||||
<View style={styles.descriptionSlot}>{description}</View>
|
||||
) : null}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccentColor(
|
||||
variant: AlertVariant,
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): string | null {
|
||||
if (variant === "info") return theme.colors.palette.blue[300];
|
||||
if (variant === "success") return theme.colors.palette.green[400];
|
||||
if (variant === "warning") return theme.colors.palette.amber[500];
|
||||
if (variant === "error") return theme.colors.destructive;
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
iconSlot: {
|
||||
paddingTop: 2,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
descriptionSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
@@ -104,7 +104,6 @@ export function Button({
|
||||
style,
|
||||
textStyle,
|
||||
disabled,
|
||||
loading = false,
|
||||
accessibilityRole,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
@@ -114,12 +113,10 @@ export function Button({
|
||||
leftIcon?: LeftIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
loading?: boolean;
|
||||
}
|
||||
>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { theme } = useUnistyles();
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
let variantStyle: ViewStyle;
|
||||
if (variant === "default") {
|
||||
@@ -153,10 +150,10 @@ export function Button({
|
||||
sizeStyle,
|
||||
variantStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
isDisabled ? styles.disabled : null,
|
||||
disabled ? styles.disabled : null,
|
||||
style,
|
||||
],
|
||||
[sizeStyle, variantStyle, isDisabled, style],
|
||||
[sizeStyle, variantStyle, disabled, style],
|
||||
);
|
||||
|
||||
const resolvedTextStyle = useMemo(
|
||||
@@ -171,30 +168,7 @@ export function Button({
|
||||
[variant, textStyle, isGhostHovered],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: isDisabled, busy: loading }),
|
||||
[isDisabled, loading],
|
||||
);
|
||||
|
||||
function resolveIconColor(): string {
|
||||
if (variant === "default") {
|
||||
return theme.colors.accentForeground;
|
||||
}
|
||||
if (variant === "ghost") {
|
||||
return isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
}
|
||||
return theme.colors.foreground;
|
||||
}
|
||||
|
||||
function renderIcon() {
|
||||
if (loading) {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator size="small" color={resolveIconColor()} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!leftIcon) return null;
|
||||
|
||||
// Pre-rendered element — pass through
|
||||
@@ -202,7 +176,14 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
const color = resolveIconColor();
|
||||
let color: string;
|
||||
if (variant === "default") {
|
||||
color = theme.colors.accentForeground;
|
||||
} else if (variant === "ghost") {
|
||||
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
} else {
|
||||
color = theme.colors.foreground;
|
||||
}
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
@@ -227,8 +208,7 @@ export function Button({
|
||||
<Pressable
|
||||
{...props}
|
||||
accessibilityRole={accessibilityRole ?? "button"}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={isDisabled}
|
||||
disabled={disabled}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./switch";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
surface3: "#333",
|
||||
accent: "#0a84ff",
|
||||
palette: { white: "#fff" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native-reanimated", () => ({
|
||||
default: {
|
||||
View: "div",
|
||||
},
|
||||
Easing: {
|
||||
ease: "ease",
|
||||
inOut: (value: unknown) => value,
|
||||
},
|
||||
interpolateColor: (value: number, _input: number[], output: string[]) =>
|
||||
value >= 1 ? output[1] : output[0],
|
||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
|
||||
withTiming: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
Pressable: ({
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
accessibilityState,
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
accessibilityLabel?: string;
|
||||
accessibilityRole?: string;
|
||||
accessibilityState?: { checked?: boolean; disabled?: boolean };
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: (event: { stopPropagation: () => void }) => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"aria-checked": accessibilityState?.checked,
|
||||
"aria-disabled": accessibilityState?.disabled,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-disabled": disabled,
|
||||
"data-testid": testID,
|
||||
onClick: () => onPress?.({ stopPropagation: vi.fn() }),
|
||||
role: accessibilityRole,
|
||||
type: "button",
|
||||
},
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
describe("Switch", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
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);
|
||||
vi.stubGlobal("document", dom.window.document);
|
||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||
vi.stubGlobal("Node", dom.window.Node);
|
||||
vi.stubGlobal("navigator", dom.window.navigator);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderSwitch(props: React.ComponentProps<typeof Switch>): HTMLElement {
|
||||
act(() => {
|
||||
root?.render(<Switch {...props} />);
|
||||
});
|
||||
|
||||
const switchElement = container?.querySelector('[role="switch"]') as HTMLElement | null;
|
||||
if (!switchElement) {
|
||||
throw new Error("Expected switch element to render");
|
||||
}
|
||||
return switchElement;
|
||||
}
|
||||
|
||||
function pressSwitch(switchElement: HTMLElement): void {
|
||||
act(() => {
|
||||
switchElement.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("renders with accessibility role switch", () => {
|
||||
const switchElement = renderSwitch({
|
||||
value: false,
|
||||
accessibilityLabel: "Enable Claude",
|
||||
testID: "provider-switch",
|
||||
});
|
||||
|
||||
expect(switchElement.getAttribute("role")).toBe("switch");
|
||||
expect(switchElement.getAttribute("aria-label")).toBe("Enable Claude");
|
||||
});
|
||||
|
||||
it("calls onValueChange with the toggled value when pressed", () => {
|
||||
const onValueChange = vi.fn();
|
||||
const switchElement = renderSwitch({ value: false, onValueChange });
|
||||
|
||||
pressSwitch(switchElement);
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1);
|
||||
expect(onValueChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not call onValueChange when disabled", () => {
|
||||
const onValueChange = vi.fn();
|
||||
const switchElement = renderSwitch({ value: false, onValueChange, disabled: true });
|
||||
|
||||
pressSwitch(switchElement);
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reflects checked accessibility state from value", () => {
|
||||
const switchElement = renderSwitch({ value: true });
|
||||
|
||||
expect(switchElement.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
root?.render(<Switch value={false} />);
|
||||
});
|
||||
|
||||
expect(switchElement.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolateColor,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface SwitchProps {
|
||||
value: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const TRACK = { width: 34, height: 20 };
|
||||
const THUMB = 16;
|
||||
|
||||
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
|
||||
|
||||
export function Switch({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
style,
|
||||
}: SwitchProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const track = TRACK;
|
||||
const thumb = THUMB;
|
||||
const padding = (track.height - thumb) / 2;
|
||||
const thumbTravel = track.width - thumb - padding * 2;
|
||||
|
||||
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
|
||||
|
||||
const trackAnimatedStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[theme.colors.surface3, theme.colors.accent],
|
||||
),
|
||||
}));
|
||||
|
||||
const thumbAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: progress.value * thumbTravel }],
|
||||
}));
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
if (disabled) return;
|
||||
onValueChange?.(!value);
|
||||
},
|
||||
[disabled, onValueChange, value],
|
||||
);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
|
||||
const pressableStyle = useMemo(
|
||||
() => [disabled ? styles.disabled : null, style],
|
||||
[disabled, style],
|
||||
);
|
||||
const trackStyle = useMemo(
|
||||
() => [
|
||||
styles.track,
|
||||
{
|
||||
width: track.width,
|
||||
height: track.height,
|
||||
borderRadius: track.height / 2,
|
||||
padding,
|
||||
},
|
||||
trackAnimatedStyle,
|
||||
],
|
||||
[track.width, track.height, padding, trackAnimatedStyle],
|
||||
);
|
||||
const thumbStyle = useMemo(
|
||||
() => [
|
||||
styles.thumb,
|
||||
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
|
||||
thumbAnimatedStyle,
|
||||
],
|
||||
[thumb, thumbAnimatedStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
hitSlop={8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<Animated.View style={trackStyle}>
|
||||
<Animated.View style={thumbStyle} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
track: {
|
||||
justifyContent: "center",
|
||||
},
|
||||
thumb: {
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
shadowColor: "rgba(0, 0, 0, 0.25)",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowRadius: 2,
|
||||
shadowOpacity: 1,
|
||||
elevation: 2,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
}));
|
||||
@@ -8,20 +8,11 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { Pressable } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -216,6 +207,7 @@ function WorkspaceHoverCardContent({
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
contentRef: React.RefObject<View | null>;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
@@ -305,7 +297,7 @@ function WorkspaceHoverCardContent({
|
||||
{prHint?.checks && prHint.checks.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
|
||||
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} />
|
||||
</>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
@@ -314,49 +306,49 @@ function WorkspaceHoverCardContent({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function ChecksSummaryContent({
|
||||
checks,
|
||||
theme,
|
||||
hovered,
|
||||
}: {
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
hovered: boolean;
|
||||
}) {
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
const pending = checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
let badgeColor: string;
|
||||
let badgeLabel: string;
|
||||
let dotStyle: StyleProp<ViewStyle>;
|
||||
let statusTextStyle: StyleProp<TextStyle>;
|
||||
|
||||
if (failed > 0) {
|
||||
badgeColor = theme.colors.palette.red[500];
|
||||
badgeLabel = `${failed} failed`;
|
||||
dotStyle = styles.checksDotFailed;
|
||||
statusTextStyle = styles.checksStatusTextFailed;
|
||||
} else if (pending > 0) {
|
||||
badgeColor = theme.colors.palette.amber[500];
|
||||
badgeLabel = `${pending} running`;
|
||||
dotStyle = styles.checksDotPending;
|
||||
statusTextStyle = styles.checksStatusTextPending;
|
||||
} else {
|
||||
badgeColor = theme.colors.palette.green[500];
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
dotStyle = styles.checksDotPassed;
|
||||
statusTextStyle = styles.checksStatusTextPassed;
|
||||
}
|
||||
|
||||
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
|
||||
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const labelStyle = useMemo(
|
||||
() => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered],
|
||||
[hovered],
|
||||
);
|
||||
const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]);
|
||||
const statusTextStyle = useMemo(
|
||||
() => [styles.checksStatusText, { color: badgeColor }],
|
||||
[badgeColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hovered ? (
|
||||
<ThemedExternalLink size={12} uniProps={iconUniProps} />
|
||||
<ExternalLink size={12} color={iconColor} />
|
||||
) : (
|
||||
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
|
||||
<GitHubIcon size={12} color={iconColor} />
|
||||
)}
|
||||
<Text style={labelStyle}>Checks</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
@@ -370,32 +362,38 @@ function ChecksSummaryContent({
|
||||
function ChecksSummaryPressable({
|
||||
checks,
|
||||
url,
|
||||
theme,
|
||||
}: {
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
url: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}) {
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(`${url}/checks`);
|
||||
}, [url]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
|
||||
styles.checksSummaryRow,
|
||||
Boolean(hovered) && styles.listRowHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
||||
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
|
||||
<ChecksSummaryContent checks={checks} theme={theme} hovered={Boolean(hovered)} />
|
||||
),
|
||||
[checks],
|
||||
[checks, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
|
||||
<Pressable style={pressableStyle} onPress={handlePress}>
|
||||
{renderChildren}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) {
|
||||
return [styles.checksSummaryRow, hovered && styles.listRowHovered];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
@@ -469,42 +467,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
checksDotFailed: {
|
||||
checksDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
},
|
||||
checksDotPending: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
},
|
||||
checksDotPassed: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
},
|
||||
checksStatusTextFailed: {
|
||||
checksStatusText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
checksStatusTextPending: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
checksStatusTextPassed: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[500],
|
||||
},
|
||||
}));
|
||||
|
||||
const checksSummaryLabelHoveredCombined = [
|
||||
styles.checksSummaryLabel,
|
||||
styles.checksSummaryLabelHovered,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
@@ -140,6 +140,7 @@ function buildCreateAgentOptions({
|
||||
}
|
||||
|
||||
export function WorkspaceSetupDialog() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
@@ -348,6 +349,10 @@ export function WorkspaceSetupDialog() {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const composerInputWrapperStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.surface2 }),
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
|
||||
const statusControlsWithDisabled = useMemo(
|
||||
() =>
|
||||
@@ -410,7 +415,7 @@ export function WorkspaceSetupDialog() {
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
inputWrapperStyle={composerInputWrapperStyle}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
</View>
|
||||
@@ -458,7 +463,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
composerInputWrapper: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
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 router = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const activeSelection = vi.hoisted(() => ({
|
||||
value: { serverId: "server-1", workspaceId: "workspace-1" } as {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
} | null,
|
||||
}));
|
||||
|
||||
const activeWorkspace = vi.hoisted(() => ({
|
||||
value: {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
|
||||
} as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const client = vi.hoisted(() => ({
|
||||
readProjectConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorage,
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
useRouter: () => router,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useNavigationActiveWorkspaceSelection: () => activeSelection.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store-hooks", () => ({
|
||||
useWorkspaceFields: (
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
project: (workspace: Record<string, unknown>) => unknown,
|
||||
) => {
|
||||
if (
|
||||
!activeWorkspace.value ||
|
||||
serverId !== activeSelection.value?.serverId ||
|
||||
workspaceId !== activeWorkspace.value.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return project(activeWorkspace.value);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: (serverId: string) => (serverId === "server-1" ? client : null),
|
||||
}));
|
||||
|
||||
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 { WorktreeSetupCalloutSource } from "./worktree-setup-callout-source";
|
||||
|
||||
function readOk(config: Record<string, unknown>) {
|
||||
return {
|
||||
ok: true,
|
||||
config,
|
||||
revision: { exists: true, mtimeMs: 1, size: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
function readError() {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "project_not_found", message: "Project not found" },
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ queryClient }: { queryClient: QueryClient }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCalloutProvider>
|
||||
<WorktreeSetupCalloutSource />
|
||||
<SidebarCalloutSlot />
|
||||
</SidebarCalloutProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, queryClient: QueryClient): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<Harness queryClient={queryClient} />);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function findByTestId(testID: string): Promise<HTMLElement | null> {
|
||||
let element: HTMLElement | null = null;
|
||||
for (let index = 0; index < 10 && !element; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
describe("WorktreeSetupCalloutSource", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
let queryClient: QueryClient | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
activeSelection.value = { serverId: "server-1", workspaceId: "workspace-1" };
|
||||
activeWorkspace.value = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/project-1" } },
|
||||
};
|
||||
client.readProjectConfig.mockReset();
|
||||
client.readProjectConfig.mockResolvedValue(readOk({}));
|
||||
router.navigate.mockClear();
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
queryClient?.clear();
|
||||
queryClient = null;
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("registers a callout for an active git workspace with missing setup", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(await findByTestId("worktree-setup-callout-project-1")).not.toBeNull();
|
||||
expect(container?.textContent).toContain("Set up worktree scripts");
|
||||
expect(container?.textContent).toContain("Open project settings");
|
||||
expect(client.readProjectConfig).toHaveBeenCalledWith("/repo/project-1");
|
||||
});
|
||||
|
||||
it("does not register a callout for a non-git workspace", async () => {
|
||||
activeWorkspace.value = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectKind: "local",
|
||||
projectRootPath: "/repo/project-1",
|
||||
};
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
expect(client.readProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register a callout when setup is present", async () => {
|
||||
client.readProjectConfig.mockResolvedValue(readOk({ worktree: { setup: "npm install" } }));
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not register a callout without an active workspace", async () => {
|
||||
activeSelection.value = null;
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
expect(client.readProjectConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register a callout when reading paseo.json fails", async () => {
|
||||
client.readProjectConfig.mockResolvedValue(readError());
|
||||
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("opens project settings from the callout action", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
const action = await findByTestId("worktree-setup-callout-project-1-action-0");
|
||||
expect(action).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith("/settings/projects/project-1");
|
||||
});
|
||||
|
||||
it("persists dismissal for the project", async () => {
|
||||
await renderHarness(root!, queryClient!);
|
||||
|
||||
const dismiss = await findByTestId("worktree-setup-callout-project-1-dismiss");
|
||||
expect(dismiss).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:sidebar-callout-dismissals",
|
||||
JSON.stringify(["worktree-setup-missing:project-1"]),
|
||||
);
|
||||
expect(container?.querySelector('[data-testid="worktree-setup-callout-project-1"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
function selectActiveGitWorkspaceProject(
|
||||
serverId: string,
|
||||
workspace: WorkspaceDescriptor,
|
||||
): ActiveGitWorkspaceProject | null {
|
||||
if (workspace.projectKind !== "git") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectKey = workspace.projectId.trim();
|
||||
const repoRoot = (workspace.project?.checkout.mainRepoRoot ?? workspace.projectRootPath).trim();
|
||||
if (!projectKey || !repoRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, projectKey, repoRoot };
|
||||
}
|
||||
|
||||
function hasSetupCommands(config: PaseoConfigRaw): boolean {
|
||||
const setup = config.worktree?.setup;
|
||||
if (typeof setup === "string") {
|
||||
return setup.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(setup)) {
|
||||
return setup.some((command) => typeof command === "string" && command.trim().length > 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useNavigationActiveWorkspaceSelection();
|
||||
const activeProject = useWorkspaceFields(
|
||||
selection?.serverId ?? null,
|
||||
selection?.workspaceId ?? null,
|
||||
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
|
||||
);
|
||||
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
|
||||
const callouts = useSidebarCallouts();
|
||||
const router = useRouter();
|
||||
const openProjectSettings = useStableEvent(() => {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
|
||||
});
|
||||
|
||||
const readQuery = useQuery({
|
||||
queryKey: ["project-config", activeProject?.serverId ?? "", activeProject?.repoRoot ?? ""],
|
||||
queryFn: () => {
|
||||
if (!client || !activeProject) {
|
||||
throw new Error("Project config query requires an active git workspace");
|
||||
}
|
||||
return client.readProjectConfig(activeProject.repoRoot);
|
||||
},
|
||||
enabled: Boolean(client && activeProject),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const shouldShow =
|
||||
activeProject !== null &&
|
||||
readQuery.data?.ok === true &&
|
||||
!hasSetupCommands(readQuery.data.config ?? {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow || !activeProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
return callouts.show({
|
||||
id: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
dismissalKey: `worktree-setup-missing:${activeProject.projectKey}`,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actions: [
|
||||
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
|
||||
],
|
||||
testID: `worktree-setup-callout-${activeProject.projectKey}`,
|
||||
});
|
||||
}, [activeProject, callouts, openProjectSettings, shouldShow]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,20 +9,22 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SidebarCallout,
|
||||
type SidebarCalloutAction,
|
||||
type SidebarCalloutProps,
|
||||
type SidebarCalloutVariant,
|
||||
} from "@/components/sidebar-callout";
|
||||
type CalloutAction,
|
||||
CalloutCard,
|
||||
type CalloutCardProps,
|
||||
type CalloutVariant,
|
||||
} from "@/components/callout-card";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
export type SidebarCalloutAction = CalloutAction;
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: SidebarCalloutVariant;
|
||||
variant?: CalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
@@ -216,7 +218,7 @@ export function SidebarCalloutViewport() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cardProps: SidebarCalloutProps = {
|
||||
const cardProps: CalloutCardProps = {
|
||||
title: activeCallout.title,
|
||||
description: activeCallout.description,
|
||||
icon: activeCallout.icon,
|
||||
@@ -227,5 +229,5 @@ export function SidebarCalloutViewport() {
|
||||
testID: activeCallout.testID,
|
||||
};
|
||||
|
||||
return <SidebarCallout {...cardProps} />;
|
||||
return <CalloutCard {...cardProps} />;
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LocalDaemonSection } from "./desktop-updates-section";
|
||||
|
||||
const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({
|
||||
alertMock: vi.fn(),
|
||||
confirmDialogMock: vi.fn(),
|
||||
settingsState: {
|
||||
settings: {
|
||||
releaseChannel: "stable" as const,
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
},
|
||||
updateSettings: vi.fn<
|
||||
(updates: {
|
||||
daemon?: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
keepRunningAfterQuit?: boolean;
|
||||
};
|
||||
}) => Promise<void>
|
||||
>(),
|
||||
},
|
||||
daemonStatusState: {
|
||||
data: {
|
||||
status: {
|
||||
serverId: "desktop",
|
||||
status: "running" as const,
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: 123,
|
||||
home: "/tmp/paseo",
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
},
|
||||
logs: {
|
||||
logPath: "/tmp/paseo/daemon.log",
|
||||
contents: "daemon log",
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
error: null as string | null,
|
||||
setStatus: vi.fn(),
|
||||
refetch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }),
|
||||
Alert: { alert: alertMock },
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
View: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function"
|
||||
? (factory as (theme: Record<string, unknown>) => unknown)({
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 6: 24 },
|
||||
borderRadius: { lg: 12 },
|
||||
fontSize: { xs: 12, sm: 14 },
|
||||
colors: {
|
||||
foreground: "#111",
|
||||
foregroundMuted: "#666",
|
||||
palette: { amber: { 500: "#f59e0b" } },
|
||||
},
|
||||
})
|
||||
: factory,
|
||||
},
|
||||
useUnistyles: () => ({
|
||||
theme: {
|
||||
iconSize: { sm: 14 },
|
||||
colors: {
|
||||
foreground: "#111",
|
||||
foregroundMuted: "#666",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("expo-clipboard", () => ({
|
||||
setStringAsync: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
Activity: icon("Activity"),
|
||||
ArrowUpRight: icon("ArrowUpRight"),
|
||||
Copy: icon("Copy"),
|
||||
FileText: icon("FileText"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/styles/settings", () => ({
|
||||
settingsStyles: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => String(prop),
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/screens/settings/settings-section", () => ({
|
||||
SettingsSection: ({
|
||||
children,
|
||||
title,
|
||||
trailing,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
title: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"section",
|
||||
null,
|
||||
React.createElement("h2", null, title),
|
||||
trailing,
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
children,
|
||||
visible,
|
||||
title,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
visible?: boolean;
|
||||
title?: string;
|
||||
}) => (visible ? React.createElement("div", { "data-title": title }, children) : null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onPress,
|
||||
disabled,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel },
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
value: boolean;
|
||||
onValueChange?: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
}) =>
|
||||
React.createElement("button", {
|
||||
type: "button",
|
||||
role: "switch",
|
||||
"aria-label": accessibilityLabel,
|
||||
"aria-checked": value,
|
||||
disabled,
|
||||
onClick: () => onValueChange?.(!value),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => ({
|
||||
useDesktopSettings: () => ({
|
||||
...settingsState,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/hooks/use-daemon-status", () => ({
|
||||
useDaemonStatus: () => daemonStatusState,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/confirm-dialog", () => ({
|
||||
confirmDialog: confirmDialogMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/updates/desktop-updates", () => ({
|
||||
isVersionMismatch: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const daemonCommandMocks = vi.hoisted(() => ({
|
||||
getCliDaemonStatusMock: vi.fn(),
|
||||
stopDesktopDaemonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/app-version", () => ({
|
||||
resolveAppVersion: vi.fn(() => "1.2.3"),
|
||||
}));
|
||||
|
||||
describe("LocalDaemonSection", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
alertMock.mockReset();
|
||||
confirmDialogMock.mockReset();
|
||||
settingsState.settings.daemon.manageBuiltInDaemon = true;
|
||||
settingsState.settings.daemon.keepRunningAfterQuit = true;
|
||||
settingsState.updateSettings.mockReset();
|
||||
settingsState.updateSettings.mockResolvedValue();
|
||||
daemonStatusState.data.status.status = "running";
|
||||
daemonStatusState.setStatus.mockReset();
|
||||
daemonStatusState.refetch.mockReset();
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
|
||||
...daemonStatusState.data.status,
|
||||
status: "stopped",
|
||||
});
|
||||
daemonCommandMocks.getCliDaemonStatusMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders the daemon toggles as switches reflecting current settings", () => {
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
expect(screen.getByText("Keep daemon running after quit")).toBeTruthy();
|
||||
expect(screen.getByText("Manage built-in daemon")).toBeTruthy();
|
||||
|
||||
const keepRunningSwitch = screen.getByRole("switch", {
|
||||
name: "Keep daemon running after quit",
|
||||
});
|
||||
expect(keepRunningSwitch.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
const manageSwitch = screen.getByRole("switch", { name: "Manage built-in daemon" });
|
||||
expect(manageSwitch.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not render a duplicate restart-daemon control", () => {
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
expect(screen.queryByText("Restart daemon")).toBeNull();
|
||||
expect(screen.queryByText("Start daemon")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates keep-running-after-quit without changing daemon lifecycle", async () => {
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Keep daemon running after quit" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
keepRunningAfterQuit: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(confirmDialogMock).not.toHaveBeenCalled();
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pauses built-in daemon management and persists the setting through desktop settings", async () => {
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,121 @@
|
||||
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
|
||||
import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
|
||||
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
||||
function getDaemonManagementButtonLabel(isUpdating: boolean, isPaused: boolean): string {
|
||||
if (isUpdating) return isPaused ? "Resuming..." : "Pausing...";
|
||||
return isPaused ? "Resume" : "Pause";
|
||||
}
|
||||
|
||||
function useDaemonManagementToggle(args: {
|
||||
function getDaemonRestartButtonLabel(
|
||||
isRestarting: boolean,
|
||||
daemonStatus: string | undefined,
|
||||
actionLabel: string,
|
||||
): string {
|
||||
if (!isRestarting) return actionLabel;
|
||||
return daemonStatus === "running" ? "Restarting..." : "Starting...";
|
||||
}
|
||||
|
||||
function useDaemonRestartAction(args: {
|
||||
showSection: boolean;
|
||||
daemonStatus: DesktopDaemonStatus | null;
|
||||
settings: DesktopDaemonSettings;
|
||||
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
|
||||
daemonActionLabel: string;
|
||||
setStatus: (status: DesktopDaemonStatus) => void;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { daemonStatus, settings, updateSettings, setStatus, refetch } = args;
|
||||
const { showSection, daemonStatus, daemonActionLabel, setStatus, refetch } = args;
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
|
||||
const handleUpdateLocalDaemon = useCallback(() => {
|
||||
if (!showSection || isRestartingDaemon) {
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmDialog({
|
||||
title: daemonActionLabel,
|
||||
message:
|
||||
daemonStatus?.status === "running"
|
||||
? "This will restart the built-in daemon. The app will reconnect automatically."
|
||||
: "This will start the built-in daemon.",
|
||||
confirmLabel: daemonActionLabel,
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRestartingDaemon(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
const action =
|
||||
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
||||
|
||||
void action()
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setStatusMessage(
|
||||
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
||||
);
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to change desktop daemon state", error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusMessage(`${daemonActionLabel} failed: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRestartingDaemon(false);
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [
|
||||
daemonActionLabel,
|
||||
daemonStatus?.status,
|
||||
isRestartingDaemon,
|
||||
refetch,
|
||||
setStatus,
|
||||
showSection,
|
||||
]);
|
||||
|
||||
return { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon };
|
||||
}
|
||||
|
||||
function useDaemonManagementToggle(args: {
|
||||
daemonStatus: DesktopDaemonStatus | null;
|
||||
settings: { manageBuiltInDaemon: boolean };
|
||||
updateSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<unknown>;
|
||||
setStatus: (status: DesktopDaemonStatus) => void;
|
||||
setStatusMessage: (message: string | null) => void;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { daemonStatus, settings, updateSettings, setStatus, setStatusMessage, refetch } = args;
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
@@ -40,7 +125,12 @@ function useDaemonManagementToggle(args: {
|
||||
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => {
|
||||
setStatusMessage("Built-in daemon management resumed.");
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
@@ -65,6 +155,7 @@ function useDaemonManagementToggle(args: {
|
||||
}
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
setStatusMessage(null);
|
||||
|
||||
const stopPromise =
|
||||
daemonStatus?.status === "running"
|
||||
@@ -80,6 +171,7 @@ function useDaemonManagementToggle(args: {
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setStatusMessage("Built-in daemon paused and stopped.");
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -100,6 +192,7 @@ function useDaemonManagementToggle(args: {
|
||||
isUpdatingDaemonManagement,
|
||||
refetch,
|
||||
setStatus,
|
||||
setStatusMessage,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
]);
|
||||
@@ -107,28 +200,6 @@ function useDaemonManagementToggle(args: {
|
||||
return { isUpdatingDaemonManagement, handleToggleDaemonManagement };
|
||||
}
|
||||
|
||||
function useKeepRunningAfterQuitToggle(args: {
|
||||
settings: DesktopDaemonSettings;
|
||||
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
|
||||
}) {
|
||||
const { settings, updateSettings } = args;
|
||||
const [isUpdatingKeepRunningAfterQuit, setIsUpdatingKeepRunningAfterQuit] = useState(false);
|
||||
|
||||
const handleToggleKeepRunningAfterQuit = useCallback(() => {
|
||||
setIsUpdatingKeepRunningAfterQuit(true);
|
||||
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update desktop quit daemon behavior", error);
|
||||
Alert.alert("Error", "Unable to update the desktop quit daemon behavior.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingKeepRunningAfterQuit(false);
|
||||
});
|
||||
}, [settings.keepRunningAfterQuit, updateSettings]);
|
||||
|
||||
return { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit };
|
||||
}
|
||||
|
||||
function useDaemonCliStatusModal() {
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
@@ -162,6 +233,10 @@ function useDaemonCliStatusModal() {
|
||||
});
|
||||
}, [cliStatusOutput]);
|
||||
|
||||
const handleRunCliStatus = useCallback(() => {
|
||||
void handleOpenCliStatus();
|
||||
}, [handleOpenCliStatus]);
|
||||
|
||||
const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []);
|
||||
|
||||
return {
|
||||
@@ -169,7 +244,7 @@ function useDaemonCliStatusModal() {
|
||||
isCliStatusModalOpen,
|
||||
isLoadingCliStatus,
|
||||
handleCopyCliStatus,
|
||||
handleOpenCliStatus,
|
||||
handleRunCliStatus,
|
||||
handleCloseCliStatusModal,
|
||||
};
|
||||
}
|
||||
@@ -222,7 +297,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
|
||||
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
@@ -273,14 +348,20 @@ interface DaemonInfoCardProps {
|
||||
daemonStatusStateText: string;
|
||||
daemonStatusDetailText: string;
|
||||
isDaemonManagementPaused: boolean;
|
||||
copyIcon: ReactElement;
|
||||
fileTextIcon: ReactElement;
|
||||
activityIcon: ReactElement;
|
||||
playIcon: React.ReactElement;
|
||||
pauseIcon: React.ReactElement;
|
||||
rotateIcon: React.ReactElement;
|
||||
copyIcon: React.ReactElement;
|
||||
fileTextIcon: React.ReactElement;
|
||||
activityIcon: React.ReactElement;
|
||||
handleToggleDaemonManagement: () => void;
|
||||
isUpdatingDaemonManagement: boolean;
|
||||
keepRunningAfterQuit: boolean;
|
||||
handleToggleKeepRunningAfterQuit: () => void;
|
||||
isUpdatingKeepRunningAfterQuit: boolean;
|
||||
daemonActionLabel: string;
|
||||
daemonActionMessage: string;
|
||||
statusMessage: string | null;
|
||||
handleUpdateLocalDaemon: () => void;
|
||||
isRestartingDaemon: boolean;
|
||||
daemonStatus: DesktopDaemonStatus | null;
|
||||
daemonLogs: { logPath?: string } | null;
|
||||
handleCopyLogPath: () => void;
|
||||
handleOpenLogs: () => void;
|
||||
@@ -293,14 +374,20 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
daemonStatusStateText,
|
||||
daemonStatusDetailText,
|
||||
isDaemonManagementPaused,
|
||||
playIcon,
|
||||
pauseIcon,
|
||||
rotateIcon,
|
||||
copyIcon,
|
||||
fileTextIcon,
|
||||
activityIcon,
|
||||
handleToggleDaemonManagement,
|
||||
isUpdatingDaemonManagement,
|
||||
keepRunningAfterQuit,
|
||||
handleToggleKeepRunningAfterQuit,
|
||||
isUpdatingKeepRunningAfterQuit,
|
||||
daemonActionLabel,
|
||||
daemonActionMessage,
|
||||
statusMessage,
|
||||
handleUpdateLocalDaemon,
|
||||
isRestartingDaemon,
|
||||
daemonStatus,
|
||||
daemonLogs,
|
||||
handleCopyLogPath,
|
||||
handleOpenLogs,
|
||||
@@ -313,7 +400,9 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Only the built-in desktop daemon is shown here.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -322,33 +411,44 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Manage built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>Let Paseo start and stop the built-in daemon</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={!isDaemonManagementPaused}
|
||||
onValueChange={handleToggleDaemonManagement}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={isDaemonManagementPaused ? playIcon : pauseIcon}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
accessibilityLabel="Manage built-in daemon"
|
||||
/>
|
||||
>
|
||||
{getDaemonManagementButtonLabel(isUpdatingDaemonManagement, isDaemonManagementPaused)}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
|
||||
<Text style={settingsStyles.rowHint}>Daemon keeps running when you quit Paseo</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={keepRunningAfterQuit}
|
||||
onValueChange={handleToggleKeepRunningAfterQuit}
|
||||
disabled={isUpdatingKeepRunningAfterQuit}
|
||||
accessibilityLabel="Keep daemon running after quit"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={rotateIcon}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{getDaemonRestartButtonLabel(isRestartingDaemon, daemonStatus?.status, daemonActionLabel)}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? "Log path unavailable"}
|
||||
{daemonLogs?.logPath ?? "Log path unavailable."}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
@@ -372,7 +472,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
@@ -393,12 +493,7 @@ export function LocalDaemonSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const appVersion = resolveAppVersion();
|
||||
const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings();
|
||||
const daemonSettings = settings.daemon;
|
||||
const updateDaemonSettings = useCallback(
|
||||
(updates: Partial<DesktopDaemonSettings>) => updateSettings({ daemon: updates }),
|
||||
[updateSettings],
|
||||
);
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
||||
|
||||
const daemonStatus = data?.status ?? null;
|
||||
@@ -409,20 +504,30 @@ export function LocalDaemonSection() {
|
||||
const daemonStatusStateText =
|
||||
statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running");
|
||||
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
|
||||
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
|
||||
const isDaemonManagementPaused = !settings.manageBuiltInDaemon;
|
||||
const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon";
|
||||
const daemonActionMessage =
|
||||
daemonStatus?.status === "running"
|
||||
? "Restarts the built-in daemon."
|
||||
: "Starts the built-in daemon.";
|
||||
|
||||
const { isRestartingDaemon, statusMessage, setStatusMessage, handleUpdateLocalDaemon } =
|
||||
useDaemonRestartAction({
|
||||
showSection,
|
||||
daemonStatus,
|
||||
daemonActionLabel,
|
||||
setStatus,
|
||||
refetch,
|
||||
});
|
||||
|
||||
const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({
|
||||
daemonStatus,
|
||||
settings: daemonSettings,
|
||||
updateSettings: updateDaemonSettings,
|
||||
settings,
|
||||
updateSettings,
|
||||
setStatus,
|
||||
setStatusMessage,
|
||||
refetch,
|
||||
});
|
||||
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
|
||||
useKeepRunningAfterQuitToggle({
|
||||
settings: daemonSettings,
|
||||
updateSettings: updateDaemonSettings,
|
||||
});
|
||||
|
||||
const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } =
|
||||
useDaemonLogsModal(daemonLogs);
|
||||
@@ -432,12 +537,9 @@ export function LocalDaemonSection() {
|
||||
isCliStatusModalOpen,
|
||||
isLoadingCliStatus,
|
||||
handleCopyCliStatus,
|
||||
handleOpenCliStatus,
|
||||
handleRunCliStatus,
|
||||
handleCloseCliStatusModal,
|
||||
} = useDaemonCliStatusModal();
|
||||
const handleRunCliStatus = useCallback(() => {
|
||||
void handleOpenCliStatus();
|
||||
}, [handleOpenCliStatus]);
|
||||
|
||||
const handleOpenAdvancedSettings = useCallback(
|
||||
() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL),
|
||||
@@ -448,6 +550,18 @@ export function LocalDaemonSection() {
|
||||
() => <ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
const playIcon = useMemo(
|
||||
() => <Play size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
const pauseIcon = useMemo(
|
||||
() => <Pause size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
const rotateIcon = useMemo(
|
||||
() => <RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
);
|
||||
const copyIcon = useMemo(
|
||||
() => <Copy size={theme.iconSize.sm} color={theme.colors.foreground} />,
|
||||
[theme.iconSize.sm, theme.colors.foreground],
|
||||
@@ -488,7 +602,7 @@ export function LocalDaemonSection() {
|
||||
trailing={advancedSettingsButton}
|
||||
testID="host-page-daemon-lifecycle-card"
|
||||
>
|
||||
{isLoading || isLoadingSettings ? (
|
||||
{isLoading ? (
|
||||
<View style={LOADING_CARD_STYLE}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -498,14 +612,20 @@ export function LocalDaemonSection() {
|
||||
daemonStatusStateText={daemonStatusStateText}
|
||||
daemonStatusDetailText={daemonStatusDetailText}
|
||||
isDaemonManagementPaused={isDaemonManagementPaused}
|
||||
playIcon={playIcon}
|
||||
pauseIcon={pauseIcon}
|
||||
rotateIcon={rotateIcon}
|
||||
copyIcon={copyIcon}
|
||||
fileTextIcon={fileTextIcon}
|
||||
activityIcon={activityIcon}
|
||||
handleToggleDaemonManagement={handleToggleDaemonManagement}
|
||||
isUpdatingDaemonManagement={isUpdatingDaemonManagement}
|
||||
keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit}
|
||||
handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit}
|
||||
isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit}
|
||||
daemonActionLabel={daemonActionLabel}
|
||||
daemonActionMessage={daemonActionMessage}
|
||||
statusMessage={statusMessage}
|
||||
handleUpdateLocalDaemon={handleUpdateLocalDaemon}
|
||||
isRestartingDaemon={isRestartingDaemon}
|
||||
daemonStatus={daemonStatus}
|
||||
daemonLogs={daemonLogs}
|
||||
handleCopyLogPath={handleCopyLogPath}
|
||||
handleOpenLogs={handleOpenLogs}
|
||||
@@ -567,6 +687,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
warningCard: {
|
||||
marginTop: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -133,7 +133,9 @@ export function IntegrationsSection() {
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>Control and script agents from your terminal</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Control and script agents from your terminal.
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
@@ -158,7 +160,7 @@ export function IntegrationsSection() {
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI
|
||||
Teach your agents to orchestrate through the CLI.
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
import type { ReleaseChannel } from "@/hooks/use-settings";
|
||||
|
||||
export const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
|
||||
|
||||
export interface DesktopSettings {
|
||||
releaseChannel: ReleaseChannel;
|
||||
daemon: {
|
||||
manageBuiltInDaemon: boolean;
|
||||
keepRunningAfterQuit: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesktopSettingsPatch {
|
||||
releaseChannel?: ReleaseChannel;
|
||||
daemon?: Partial<DesktopSettings["daemon"]>;
|
||||
}
|
||||
|
||||
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function useDesktopSettings(): {
|
||||
settings: DesktopSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: DesktopSettingsPatch) => Promise<void>;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: DESKTOP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadDesktopSettings,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: DesktopSettingsPatch) => {
|
||||
if (!getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous =
|
||||
queryClient.getQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
|
||||
DEFAULT_DESKTOP_SETTINGS;
|
||||
const next = mergeDesktopSettings(previous, updates);
|
||||
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, next);
|
||||
const persisted = await updatePersistedDesktopSettings(updates);
|
||||
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_DESKTOP_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDesktopSettings(): Promise<DesktopSettings> {
|
||||
if (!getIsElectron()) {
|
||||
return DEFAULT_DESKTOP_SETTINGS;
|
||||
}
|
||||
return parseDesktopSettings(await invokeDesktopCommand<unknown>("get_desktop_settings"));
|
||||
}
|
||||
|
||||
export async function updatePersistedDesktopSettings(
|
||||
updates: DesktopSettingsPatch,
|
||||
): Promise<DesktopSettings> {
|
||||
if (!getIsElectron()) {
|
||||
return DEFAULT_DESKTOP_SETTINGS;
|
||||
}
|
||||
return parseDesktopSettings(
|
||||
await invokeDesktopCommand<unknown>("patch_desktop_settings", normalizePatch(updates)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function migrateLegacyDesktopSettings(input: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
}): Promise<void> {
|
||||
if (!getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
await invokeDesktopCommand("migrate_legacy_desktop_settings", input);
|
||||
}
|
||||
|
||||
function parseDesktopSettings(raw: unknown): DesktopSettings {
|
||||
const record = isRecord(raw) ? raw : {};
|
||||
const daemon = isRecord(record.daemon) ? record.daemon : {};
|
||||
|
||||
return {
|
||||
releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable",
|
||||
daemon: {
|
||||
manageBuiltInDaemon:
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
? daemon.manageBuiltInDaemon
|
||||
: DEFAULT_DESKTOP_SETTINGS.daemon.manageBuiltInDaemon,
|
||||
keepRunningAfterQuit:
|
||||
typeof daemon.keepRunningAfterQuit === "boolean"
|
||||
? daemon.keepRunningAfterQuit
|
||||
: DEFAULT_DESKTOP_SETTINGS.daemon.keepRunningAfterQuit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDesktopSettings(
|
||||
current: DesktopSettings,
|
||||
updates: DesktopSettingsPatch,
|
||||
): DesktopSettings {
|
||||
return {
|
||||
releaseChannel: updates.releaseChannel ?? current.releaseChannel,
|
||||
daemon: {
|
||||
...current.daemon,
|
||||
...updates.daemon,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> {
|
||||
return {
|
||||
...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}),
|
||||
...(updates.daemon ? { daemon: updates.daemon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Gift } from "lucide-react-native";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
type SidebarCalloutAction,
|
||||
SidebarCalloutDescriptionText,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { CalloutDescriptionText } from "@/components/callout-card";
|
||||
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -170,14 +167,14 @@ export function UpdateCalloutSource() {
|
||||
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
|
||||
return (
|
||||
<>
|
||||
<SidebarCalloutDescriptionText>
|
||||
<CalloutDescriptionText>
|
||||
{versionLabel
|
||||
? `${versionLabel} is ready to install.`
|
||||
: "A new version is ready to install."}
|
||||
</SidebarCalloutDescriptionText>
|
||||
<SidebarCalloutDescriptionText>
|
||||
</CalloutDescriptionText>
|
||||
<CalloutDescriptionText>
|
||||
Upgrading the app will stop running agents and close terminal sessions.
|
||||
</SidebarCalloutDescriptionText>
|
||||
</CalloutDescriptionText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDesktopAppUpdater } from "./use-desktop-app-updater";
|
||||
|
||||
const desktopUpdatesMock = vi.hoisted(() => ({
|
||||
shouldShowDesktopUpdateSection: vi.fn(() => true),
|
||||
checkDesktopAppUpdate: vi.fn(async () => ({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
latestVersion: null,
|
||||
})),
|
||||
installDesktopAppUpdate: vi.fn(async () => ({
|
||||
installed: false,
|
||||
message: null,
|
||||
})),
|
||||
formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"),
|
||||
}));
|
||||
|
||||
const settingsState = vi.hoisted(() => ({
|
||||
releaseChannel: "stable" as "stable" | "beta",
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock);
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => ({
|
||||
useDesktopSettings: () => ({
|
||||
settings: {
|
||||
releaseChannel: settingsState.releaseChannel,
|
||||
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
updateSettings: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useDesktopAppUpdater", () => {
|
||||
beforeEach(() => {
|
||||
settingsState.releaseChannel = "stable";
|
||||
desktopUpdatesMock.checkDesktopAppUpdate.mockClear();
|
||||
desktopUpdatesMock.installDesktopAppUpdate.mockClear();
|
||||
});
|
||||
|
||||
it("uses the effective desktop release channel when checking for updates", async () => {
|
||||
settingsState.releaseChannel = "beta";
|
||||
|
||||
renderHook(() => useDesktopAppUpdater());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the effective desktop release channel when installing updates", async () => {
|
||||
settingsState.releaseChannel = "beta";
|
||||
const { result } = renderHook(() => useDesktopAppUpdater());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installUpdate();
|
||||
});
|
||||
|
||||
expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type DesktopAppUpdateCheckResult,
|
||||
type DesktopAppUpdateInstallResult,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
@@ -84,8 +84,8 @@ function formatStatusText(input: {
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const { settings: desktopSettings } = useDesktopSettings();
|
||||
const releaseChannel = desktopSettings.releaseChannel;
|
||||
const { settings } = useAppSettings();
|
||||
const releaseChannel = settings.releaseChannel;
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
|
||||
@@ -78,7 +78,6 @@ describe("useAgentFormState live preference hydration", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
defaultModeId: "auto",
|
||||
@@ -108,7 +107,6 @@ describe("useAgentFormState live preference hydration", () => {
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
description: "Claude test provider",
|
||||
defaultModeId: "default",
|
||||
|
||||
@@ -53,7 +53,6 @@ describe("useAgentFormState", () => {
|
||||
{
|
||||
provider: "zai",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI config",
|
||||
defaultModeId: "default",
|
||||
@@ -70,7 +69,6 @@ describe("useAgentFormState", () => {
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
description: "Anthropic Claude",
|
||||
defaultModeId: "default",
|
||||
@@ -546,7 +544,6 @@ describe("useAgentFormState", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
@@ -555,7 +552,6 @@ describe("useAgentFormState", () => {
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
@@ -600,111 +596,11 @@ describe("useAgentFormState", () => {
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: false,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: entries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||
});
|
||||
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "claude" },
|
||||
null,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
selectableProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("codex");
|
||||
expect(resolved.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
modes: TEST_CODEX_DEFINITION.modes,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: false,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||
},
|
||||
];
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
|
||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||
snapshotEntries: entries,
|
||||
providerDefinitions,
|
||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||
});
|
||||
|
||||
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
|
||||
const snapshotProviderStates = [
|
||||
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
|
||||
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
|
||||
];
|
||||
|
||||
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
||||
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
|
||||
expect(snapshotProviderStates).toEqual([
|
||||
{ provider: "codex", enabled: true },
|
||||
{ provider: "claude", enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
enabled: true,
|
||||
label: TEST_CODEX_DEFINITION.label,
|
||||
description: TEST_CODEX_DEFINITION.description,
|
||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||
@@ -713,7 +609,6 @@ describe("useAgentFormState", () => {
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: TEST_CLAUDE_DEFINITION.label,
|
||||
description: TEST_CLAUDE_DEFINITION.description,
|
||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||
|
||||
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
|
||||
|
||||
const matchingProviders = new Set(
|
||||
args.snapshotEntries
|
||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
|
||||
.filter((entry) => args.statuses.has(entry.status))
|
||||
.map((entry) => entry.provider),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { useProjects } from "./use-projects";
|
||||
|
||||
interface MockDaemonClient {
|
||||
fetchWorkspaces: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const { hostState, clientsByServerId, runtimeStore } = vi.hoisted(() => {
|
||||
const state = {
|
||||
hosts: [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
],
|
||||
snapshots: new Map<string, { connectionStatus: string }>(),
|
||||
};
|
||||
const clients = new Map<string, { fetchWorkspaces: ReturnType<typeof vi.fn> }>();
|
||||
return {
|
||||
hostState: state,
|
||||
clientsByServerId: clients,
|
||||
runtimeStore: {
|
||||
getClient: (serverId: string) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId: string) => state.snapshots.get(serverId) ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => runtimeStore,
|
||||
useHosts: () => hostState.hosts,
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectsHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useProjects(), { wrapper });
|
||||
}
|
||||
|
||||
function makeClient(entries: WorkspaceDescriptorPayload[] | Error): MockDaemonClient {
|
||||
return {
|
||||
fetchWorkspaces: vi.fn(async () => {
|
||||
if (entries instanceof Error) {
|
||||
throw entries;
|
||||
}
|
||||
return {
|
||||
requestId: "req-workspaces",
|
||||
entries,
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: false,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
},
|
||||
githubRuntime: null,
|
||||
project: {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName,
|
||||
checkout: {
|
||||
cwd: input.cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: input.remoteUrl,
|
||||
worktreeRoot: input.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clientsByServerId.clear();
|
||||
hostState.hosts = [
|
||||
{ serverId: "local", label: "Local" },
|
||||
{ serverId: "laptop", label: "Laptop" },
|
||||
];
|
||||
hostState.snapshots = new Map<string, { connectionStatus: string }>();
|
||||
});
|
||||
|
||||
describe("useProjects", () => {
|
||||
it("calls all known daemon clients and returns aggregated projects sorted by display name", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "z-main",
|
||||
projectKey: "remote:github.com/acme/zeta",
|
||||
projectName: "acme/zeta",
|
||||
cwd: "/repo/zeta",
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set(
|
||||
"laptop",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(clientsByServerId.get("local")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(clientsByServerId.get("laptop")?.fetchWorkspaces).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
clientsByServerId.set("laptop", makeClient(new Error("laptop unavailable")));
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(result.current.hostErrors).toEqual([
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
hostState.snapshots.set("laptop", { connectionStatus: "offline" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(result.current.hostErrors).toEqual([]);
|
||||
expect(result.current.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", async () => {
|
||||
hostState.snapshots.set("local", { connectionStatus: "online" });
|
||||
clientsByServerId.set(
|
||||
"local",
|
||||
makeClient([
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const { result } = renderProjectsHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0] ?? {}).sort()).toEqual([
|
||||
"gitRuntime",
|
||||
"githubRuntime",
|
||||
"isOnline",
|
||||
"repoRoot",
|
||||
"serverId",
|
||||
"serverName",
|
||||
"workspaceCount",
|
||||
"workspaces",
|
||||
]);
|
||||
expect(Object.keys(result.current.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([
|
||||
"currentBranch",
|
||||
"id",
|
||||
"name",
|
||||
"status",
|
||||
"workspaceKind",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
|
||||
export interface ProjectHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface FetchAllWorkspaceDescriptorsInput {
|
||||
client: Pick<DaemonClient, "fetchWorkspaces">;
|
||||
}
|
||||
|
||||
interface HostWorkspacesResult {
|
||||
host: ProjectHost;
|
||||
error: ProjectHostError | null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function fetchAllWorkspaceDescriptors(
|
||||
input: FetchAllWorkspaceDescriptorsInput,
|
||||
): Promise<WorkspaceDescriptor[]> {
|
||||
const entries: WorkspaceDescriptor[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const payload = await input.client.fetchWorkspaces({
|
||||
sort: [{ key: "name", direction: "asc" }],
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
entries.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry)));
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = payload.pageInfo.nextCursor;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const hostInputs = useMemo(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
hostInputs.map(async (host): Promise<HostWorkspacesResult> => {
|
||||
const snapshot = runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: await fetchAllWorkspaceDescriptors({ client }),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
host: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
workspaces: [],
|
||||
},
|
||||
error: {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
message: toErrorMessage(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hostErrors = results.flatMap((result) => (result.error ? [result.error] : []));
|
||||
return {
|
||||
...buildProjects({ hosts: results.map((result) => result.host) }),
|
||||
hostErrors,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
refetch: () => {
|
||||
void projectsQuery.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -101,7 +101,6 @@ function codexEntry(
|
||||
return {
|
||||
provider: "codex",
|
||||
status,
|
||||
enabled: true,
|
||||
...(models ? { models } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,33 +5,15 @@ const asyncStorageMock = vi.hoisted(() => ({
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const electronRuntimeState = vi.hoisted(() => ({
|
||||
isElectron: false,
|
||||
}));
|
||||
|
||||
const desktopSettingsMock = vi.hoisted(() => ({
|
||||
loadDesktopSettings: vi.fn<() => Promise<unknown>>(),
|
||||
migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/host", () => ({
|
||||
isElectronRuntime: () => electronRuntimeState.isElectron,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock);
|
||||
|
||||
describe("use-settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
electronRuntimeState.isElectron = false;
|
||||
desktopSettingsMock.loadDesktopSettings.mockReset();
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockReset();
|
||||
});
|
||||
|
||||
it("defaults built-in daemon management to enabled when storage is empty", async () => {
|
||||
@@ -44,7 +26,7 @@ describe("use-settings", () => {
|
||||
expect(result).toEqual(mod.DEFAULT_APP_SETTINGS);
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS),
|
||||
JSON.stringify(mod.DEFAULT_APP_SETTINGS),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,7 +50,7 @@ describe("use-settings", () => {
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("ignores renderer-owned daemon management state outside Electron", async () => {
|
||||
it("loads persisted built-in daemon management state", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
@@ -84,13 +66,14 @@ describe("use-settings", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: true,
|
||||
manageBuiltInDaemon: false,
|
||||
sendBehavior: "interrupt",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores renderer-owned release channel outside Electron", async () => {
|
||||
it("loads persisted beta release channel", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
@@ -103,89 +86,6 @@ describe("use-settings", () => {
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result.releaseChannel).toBe("stable");
|
||||
});
|
||||
|
||||
it("keeps legacy AsyncStorage migration for client settings only", async () => {
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return null;
|
||||
}
|
||||
if (key === "@paseo:settings") {
|
||||
return JSON.stringify({
|
||||
theme: "dark",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadAppSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
});
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => {
|
||||
electronRuntimeState.isElectron = true;
|
||||
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
|
||||
if (key === "@paseo:app-settings") {
|
||||
return JSON.stringify({
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue();
|
||||
desktopSettingsMock.loadDesktopSettings.mockResolvedValue({
|
||||
releaseChannel: "beta",
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
keepRunningAfterQuit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips desktop IPC when loading effective settings outside Electron", async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
theme: "light",
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import("./use-settings");
|
||||
const result = await mod.loadSettingsFromStorage();
|
||||
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
expect(result.releaseChannel).toBe("beta");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,46 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
migrateLegacyDesktopSettings,
|
||||
useDesktopSettings,
|
||||
} from "@/desktop/settings/desktop-settings";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_RELEASE_CHANNELS = new Set<string>(["stable", "beta"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
manageBuiltInDaemon: boolean;
|
||||
sendBehavior: SendBehavior;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
releaseChannel: "stable",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
error: unknown | null;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadAppSettingsFromStorage,
|
||||
queryFn: loadSettingsFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
@@ -69,7 +49,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
const prev =
|
||||
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS;
|
||||
queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
@@ -83,7 +63,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
const next = { ...DEFAULT_CLIENT_SETTINGS };
|
||||
const next = { ...DEFAULT_APP_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
@@ -93,7 +73,7 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings: data ?? DEFAULT_CLIENT_SETTINGS,
|
||||
settings: data ?? DEFAULT_APP_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
@@ -101,172 +81,51 @@ export function useAppSettings(): UseAppSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const appSettings = useAppSettings();
|
||||
const desktopSettings = useDesktopSettings();
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<Settings>) => {
|
||||
const appUpdates: Partial<AppSettings> = {};
|
||||
if (updates.theme !== undefined) {
|
||||
appUpdates.theme = updates.theme;
|
||||
}
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
}
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
const desktopUpdates: Parameters<typeof desktopSettings.updateSettings>[0] = {};
|
||||
if (updates.manageBuiltInDaemon !== undefined) {
|
||||
desktopUpdates.daemon = {
|
||||
manageBuiltInDaemon: updates.manageBuiltInDaemon,
|
||||
};
|
||||
}
|
||||
if (updates.releaseChannel !== undefined) {
|
||||
desktopUpdates.releaseChannel = updates.releaseChannel;
|
||||
}
|
||||
if (Object.keys(desktopUpdates).length > 0) {
|
||||
promises.push(desktopSettings.updateSettings(desktopUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[appSettings, desktopSettings],
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
const resets: Promise<void>[] = [appSettings.resetSettings()];
|
||||
if (isElectronRuntime()) {
|
||||
resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS));
|
||||
}
|
||||
await Promise.all(resets);
|
||||
}, [appSettings, desktopSettings]);
|
||||
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings.settings,
|
||||
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.settings.releaseChannel,
|
||||
},
|
||||
isLoading: appSettings.isLoading || desktopSettings.isLoading,
|
||||
error: appSettings.error ?? desktopSettings.error,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
|
||||
export async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) };
|
||||
if (parsed.theme && !VALID_THEMES.has(parsed.theme)) {
|
||||
parsed.theme = DEFAULT_APP_SETTINGS.theme;
|
||||
}
|
||||
if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) {
|
||||
parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel;
|
||||
}
|
||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
}
|
||||
|
||||
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS));
|
||||
return DEFAULT_CLIENT_SETTINGS;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
|
||||
return DEFAULT_APP_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSettingsFromStorage(): Promise<Settings> {
|
||||
const legacyDesktopSettings = isElectronRuntime()
|
||||
? await loadLegacyDesktopSettingsFromStorage()
|
||||
: null;
|
||||
const appSettings = await loadAppSettingsFromStorage();
|
||||
|
||||
if (!isElectronRuntime()) {
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
};
|
||||
}
|
||||
|
||||
if (legacyDesktopSettings) {
|
||||
await migrateLegacyDesktopSettings(legacyDesktopSettings);
|
||||
}
|
||||
|
||||
const desktopSettings = await loadDesktopSettings();
|
||||
return {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...appSettings,
|
||||
manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon,
|
||||
releaseChannel: desktopSettings.releaseChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme as AppSettings["theme"];
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
if (typeof legacy.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = legacy.manageBuiltInDaemon;
|
||||
}
|
||||
if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") {
|
||||
result.releaseChannel = legacy.releaseChannel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} | null> {
|
||||
const stored = await loadRendererSettingsPayload();
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: {
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
} = {};
|
||||
|
||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||
}
|
||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
||||
result.releaseChannel = stored.releaseChannel;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
async function loadRendererSettingsPayload(): Promise<Record<string, unknown> | null> {
|
||||
const current = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (current) {
|
||||
return JSON.parse(current) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (!legacy) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(legacy) as Record<string, unknown>;
|
||||
}
|
||||
export const useSettings = useAppSettings;
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
@@ -58,7 +57,6 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
@@ -79,7 +77,6 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
|
||||
@@ -108,7 +108,7 @@ vi.mock("react-native-unistyles", () => {
|
||||
StyleSheet: {
|
||||
create: createPanelTestStyles,
|
||||
},
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
useUnistyles: () => ({ theme }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -14,7 +14,7 @@ import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
@@ -126,8 +125,9 @@ function buildChatAgentFromState(
|
||||
function renderChatAgentNonReadyView(args: {
|
||||
viewState: AgentScreenViewState;
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}): React.ReactElement | null {
|
||||
const { viewState, effectiveAgent } = args;
|
||||
const { viewState, effectiveAgent, theme } = args;
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
@@ -151,7 +151,7 @@ function renderChatAgentNonReadyView(args: {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -306,6 +306,8 @@ type RouteBottomAnchorRequest = ReturnType<typeof deriveRouteBottomAnchorRequest
|
||||
type PendingCreateByDraftId = ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
|
||||
type PendingCreateAttempt = PendingCreateByDraftId[string];
|
||||
|
||||
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
@@ -417,6 +419,7 @@ function AgentPanelBody({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
@@ -546,7 +549,7 @@ function AgentPanelBody({
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -582,6 +585,7 @@ function ChatAgentContent({
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const panelToast = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const streamViewRef = useRef<AgentStreamViewHandle>(null);
|
||||
@@ -813,10 +817,14 @@ function ChatAgentContent({
|
||||
);
|
||||
|
||||
const handleComposerHeightChange = useCallback(
|
||||
(_height: number) => {
|
||||
(height: number) => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
logWebStickyBottom("screen_composer_height_change", {
|
||||
agentId,
|
||||
height,
|
||||
});
|
||||
streamViewRef.current?.prepareForViewportChange();
|
||||
},
|
||||
[agentId],
|
||||
@@ -826,6 +834,9 @@ function ChatAgentContent({
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
||||
agentId,
|
||||
});
|
||||
streamViewRef.current?.scrollToBottom("message-sent");
|
||||
}, [agentId]);
|
||||
|
||||
@@ -934,6 +945,7 @@ function ChatAgentContent({
|
||||
const nonReadyView = renderChatAgentNonReadyView({
|
||||
viewState,
|
||||
effectiveAgent,
|
||||
theme,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
@@ -978,7 +990,7 @@ function ChatAgentContent({
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay" ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -992,7 +1004,7 @@ function ChatAgentContent({
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foreground} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
@@ -1328,15 +1340,6 @@ function AgentSessionUnavailableState({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
@@ -1349,7 +1352,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
...(isWeb ? { userSelect: "none" as const } : {}),
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useWorkspaceSetupStore,
|
||||
type WorkspaceSetupSnapshot,
|
||||
@@ -63,13 +62,15 @@ function useSetupPanelDescriptor(
|
||||
type CommandStatus = "running" | "completed" | "failed";
|
||||
|
||||
function CommandStatusIcon({ status }: { status: CommandStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (status === "running") {
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
|
||||
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return <ThemedCheckCircle2 size={14} uniProps={greenColorMapping} />;
|
||||
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
|
||||
}
|
||||
return <ThemedCircleAlert size={14} uniProps={redColorMapping} />;
|
||||
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -150,6 +151,7 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
||||
}
|
||||
|
||||
function SetupPanel() {
|
||||
const { theme } = useUnistyles();
|
||||
const { serverId, target } = usePaneContext();
|
||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||
|
||||
@@ -229,7 +231,7 @@ function SetupPanel() {
|
||||
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.waitingText}>Setting up workspace...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -267,6 +269,7 @@ function SetupPanel() {
|
||||
hasError={rowState.hasError}
|
||||
processedLog={rowState.processedLog}
|
||||
errorMessage={snapshot?.error ?? null}
|
||||
foregroundMutedColor={theme.colors.foregroundMuted}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
);
|
||||
@@ -289,6 +292,7 @@ interface SetupCommandRowProps {
|
||||
hasError: boolean;
|
||||
processedLog: string;
|
||||
errorMessage: string | null;
|
||||
foregroundMutedColor: string;
|
||||
onToggle: (index: number, isAutoExpanded: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -301,6 +305,7 @@ function SetupCommandRow({
|
||||
hasError,
|
||||
processedLog,
|
||||
errorMessage,
|
||||
foregroundMutedColor,
|
||||
onToggle,
|
||||
}: SetupCommandRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -336,7 +341,7 @@ function SetupCommandRow({
|
||||
{command.durationMs != null ? (
|
||||
<Text style={styles.commandDuration}>{formatDuration(command.durationMs)}</Text>
|
||||
) : null}
|
||||
<SetupCommandChevron showDetail={showDetail} />
|
||||
<SetupCommandChevron showDetail={showDetail} color={foregroundMutedColor} />
|
||||
</Pressable>
|
||||
{showDetail ? (
|
||||
<View style={styles.commandDetail}>
|
||||
@@ -383,14 +388,12 @@ export const setupPanelRegistration: PanelRegistration<"setup"> = {
|
||||
useDescriptor: useSetupPanelDescriptor,
|
||||
};
|
||||
|
||||
function SetupCommandChevron({ showDetail }: { showDetail: boolean }) {
|
||||
function SetupCommandChevron({ showDetail, color }: { showDetail: boolean; color: string }) {
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, showDetail && styles.chevronExpanded],
|
||||
[showDetail],
|
||||
);
|
||||
return (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} style={chevronStyle} />
|
||||
);
|
||||
return <ChevronRight size={14} color={color} style={chevronStyle} />;
|
||||
}
|
||||
|
||||
function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) {
|
||||
@@ -429,24 +432,6 @@ function TopLevelSetupError({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCheckCircle2 = withUnistyles(CheckCircle2);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DaemonStartService } from "./daemon-start-service";
|
||||
import type { HostRuntimeStore } from "./host-runtime";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
interface RecordedUpsert {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
function createFakeStore(): {
|
||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
upserts: RecordedUpsert[];
|
||||
} {
|
||||
const upserts: RecordedUpsert[] = [];
|
||||
const store = {
|
||||
upsertConnectionFromListen: async (input: RecordedUpsert) => {
|
||||
upserts.push(input);
|
||||
return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>;
|
||||
},
|
||||
};
|
||||
return { store, upserts };
|
||||
}
|
||||
|
||||
function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemonStatus {
|
||||
return {
|
||||
serverId: "srv_desktop",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "desktop",
|
||||
pid: 1234,
|
||||
home: "/home",
|
||||
version: "0.0.0",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DaemonStartService", () => {
|
||||
it("upserts the connection on a successful daemon start", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus(),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fake.upserts).toEqual([
|
||||
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
|
||||
]);
|
||||
expect(service.getLastError()).toBeNull();
|
||||
expect(service.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("reports lastError after a missing listen address and clears running state when done", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a listen address.",
|
||||
});
|
||||
expect(service.getLastError()).toBe("Desktop daemon did not return a listen address.");
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the daemon does not return a server id", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ serverId: "" }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "Desktop daemon did not return a server id." });
|
||||
expect(service.getLastError()).toBe("Desktop daemon did not return a server id.");
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the listen address is unsupported", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: "???" }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(service.getLastError()).toContain("unsupported listen address");
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the underlying start call throws", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => {
|
||||
throw new Error("ipc broke");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "ipc broke" });
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
});
|
||||
|
||||
it("clears lastError on retry entry and reports null after subsequent success", async () => {
|
||||
const fake = createFakeStore();
|
||||
const startMock = vi
|
||||
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () => startMock(),
|
||||
});
|
||||
|
||||
const failure = await service.start();
|
||||
expect(failure.ok).toBe(false);
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
|
||||
const success = await service.start();
|
||||
expect(success).toEqual({ ok: true });
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies subscribers when isRunning toggles between calls", async () => {
|
||||
const fake = createFakeStore();
|
||||
let resolveStart: ((value: DesktopDaemonStatus) => void) | undefined;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () =>
|
||||
new Promise<DesktopDaemonStatus>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
const runningSnapshots: boolean[] = [];
|
||||
service.subscribe(() => {
|
||||
runningSnapshots.push(service.isRunning());
|
||||
});
|
||||
|
||||
const startPromise = service.start();
|
||||
expect(service.isRunning()).toBe(true);
|
||||
expect(runningSnapshots).toEqual([true]);
|
||||
|
||||
resolveStart?.(makeStatus());
|
||||
await startPromise;
|
||||
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(runningSnapshots).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("clears the error and notifies subscribers when retry begins", async () => {
|
||||
const fake = createFakeStore();
|
||||
const startMock = vi
|
||||
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () => startMock(),
|
||||
});
|
||||
|
||||
await service.start();
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
|
||||
const errorSnapshots: Array<string | null> = [];
|
||||
service.subscribe(() => {
|
||||
errorSnapshots.push(service.getLastError());
|
||||
});
|
||||
|
||||
await service.start();
|
||||
expect(errorSnapshots[0]).toBeNull();
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||
const fake = createFakeStore();
|
||||
let notifications = 0;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||
});
|
||||
const unsubscribe = service.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
await service.start();
|
||||
const countAfterFirst = notifications;
|
||||
expect(countAfterFirst).toBeGreaterThan(0);
|
||||
|
||||
unsubscribe();
|
||||
await service.start();
|
||||
expect(notifications).toBe(countAfterFirst);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectionFromListen } from "@/types/host-connection";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface DaemonStartServiceDeps {
|
||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||
}
|
||||
|
||||
export class DaemonStartService {
|
||||
private readonly store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private lastError: string | null = null;
|
||||
private inFlightCount = 0;
|
||||
|
||||
constructor(deps: DaemonStartServiceDeps) {
|
||||
this.store = deps.store;
|
||||
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
|
||||
}
|
||||
|
||||
async start(): Promise<DaemonStartResult> {
|
||||
this.beginRequest();
|
||||
try {
|
||||
const daemon = await this.invokeStartDesktopDaemon();
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
const serverId = daemon.serverId.trim();
|
||||
if (!listenAddress) {
|
||||
return this.fail("Desktop daemon did not return a listen address.");
|
||||
}
|
||||
if (!serverId) {
|
||||
return this.fail("Desktop daemon did not return a server id.");
|
||||
}
|
||||
if (!connectionFromListen(listenAddress)) {
|
||||
return this.fail(`Desktop daemon returned an unsupported listen address: ${listenAddress}`);
|
||||
}
|
||||
await this.store.upsertConnectionFromListen({
|
||||
listenAddress,
|
||||
serverId,
|
||||
hostname: daemon.hostname,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return this.fail(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
this.endRequest();
|
||||
}
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.inFlightCount > 0;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private fail(message: string): DaemonStartResult {
|
||||
this.setLastError(message);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
private setLastError(value: string | null): void {
|
||||
if (this.lastError === value) {
|
||||
return;
|
||||
}
|
||||
this.lastError = value;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private beginRequest(): void {
|
||||
const becameRunning = this.inFlightCount === 0;
|
||||
this.inFlightCount += 1;
|
||||
const errorChanged = this.lastError !== null;
|
||||
this.lastError = null;
|
||||
if (becameRunning || errorChanged) {
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private endRequest(): void {
|
||||
this.inFlightCount = Math.max(0, this.inFlightCount - 1);
|
||||
if (this.inFlightCount === 0) {
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singletonDaemonStartService: DaemonStartService | null = null;
|
||||
const DAEMON_START_SERVICE_GLOBAL_KEY = "__paseoDaemonStartService";
|
||||
|
||||
type DaemonStartServiceGlobal = typeof globalThis & {
|
||||
[DAEMON_START_SERVICE_GLOBAL_KEY]?: DaemonStartService;
|
||||
};
|
||||
|
||||
export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStartService {
|
||||
if (singletonDaemonStartService) {
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
|
||||
const runtimeGlobal = globalThis as DaemonStartServiceGlobal;
|
||||
if (runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY]) {
|
||||
singletonDaemonStartService = runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] ?? null;
|
||||
if (singletonDaemonStartService) {
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
}
|
||||
|
||||
singletonDaemonStartService = new DaemonStartService(deps);
|
||||
runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService;
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
@@ -37,6 +37,10 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
|
||||
export type HostRuntimeBootstrapResult =
|
||||
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
@@ -1146,6 +1150,7 @@ export class HostRuntimeController {
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
|
||||
export class HostRuntimeStore {
|
||||
@@ -1159,7 +1164,8 @@ export class HostRuntimeStore {
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private bootStarted = false;
|
||||
private storageLoaded = false;
|
||||
private bootstrapAttempted = false;
|
||||
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
@@ -1182,33 +1188,11 @@ export class HostRuntimeStore {
|
||||
return this.hostListVersion;
|
||||
}
|
||||
|
||||
boot(): void {
|
||||
if (this.bootStarted) {
|
||||
async loadFromStorage(): Promise<void> {
|
||||
if (this.storageLoaded) {
|
||||
return;
|
||||
}
|
||||
this.bootStarted = true;
|
||||
void this.runBoot();
|
||||
}
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
await this.loadFromStorage();
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (isE2E) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
this.storageLoaded = true;
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
@@ -1229,6 +1213,67 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||
if (this.bootstrapAttempted) {
|
||||
return;
|
||||
}
|
||||
this.bootstrapAttempted = true;
|
||||
|
||||
try {
|
||||
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
if (isE2E) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
const serverId = daemon.serverId.trim();
|
||||
if (!listenAddress) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a listen address.",
|
||||
};
|
||||
}
|
||||
if (!serverId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a server id.",
|
||||
};
|
||||
}
|
||||
if (!connectionFromListen(listenAddress)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
listenAddress,
|
||||
serverId,
|
||||
hostname: daemon.hostname,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapLocalhost(): Promise<void> {
|
||||
try {
|
||||
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
|
||||
@@ -1324,10 +1369,11 @@ export class HostRuntimeStore {
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
}
|
||||
|
||||
async upsertConnectionFromListen(input: {
|
||||
async addConnectionFromListenAndWaitForOnline(input: {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<HostProfile> {
|
||||
const normalizedListenAddress = input.listenAddress.trim();
|
||||
const serverId = input.serverId.trim();
|
||||
@@ -1338,11 +1384,19 @@ export class HostRuntimeStore {
|
||||
if (!serverId) {
|
||||
throw new Error("Desktop daemon did not return a server id.");
|
||||
}
|
||||
return this.upsertHostConnection({
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.hostname ?? undefined,
|
||||
connection,
|
||||
});
|
||||
|
||||
await this.waitForConnectionOnline({
|
||||
serverId,
|
||||
connectionId: connection.id,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async renameHost(serverId: string, label: string): Promise<void> {
|
||||
@@ -1506,6 +1560,97 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private waitForConnectionOnline(input: {
|
||||
serverId: string;
|
||||
connectionId: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const { serverId, connectionId } = input;
|
||||
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = (unsubscribe: (() => void) | null): void => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
};
|
||||
|
||||
const settle = (
|
||||
unsubscribe: (() => void) | null,
|
||||
outcome: { ok: true } | { ok: false; error: Error },
|
||||
): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup(unsubscribe);
|
||||
if (outcome.ok) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(outcome.error);
|
||||
}
|
||||
};
|
||||
|
||||
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
|
||||
const snapshot = this.getSnapshot(serverId);
|
||||
if (!snapshot) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Unknown host runtime for serverId ${serverId}`),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.activeConnectionId === connectionId &&
|
||||
snapshot.connectionStatus === "online"
|
||||
) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (snapshot.activeConnectionId === connectionId && snapshot.connectionStatus === "error") {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const unsubscribe = this.subscribe(serverId, () => {
|
||||
const outcome = readSnapshot();
|
||||
if (outcome) {
|
||||
settle(unsubscribe, outcome);
|
||||
}
|
||||
});
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
settle(unsubscribe, {
|
||||
ok: false,
|
||||
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
const initialOutcome = readSnapshot();
|
||||
if (initialOutcome) {
|
||||
settle(unsubscribe, initialOutcome);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.runProbeCycleNow(serverId).catch((error) => {
|
||||
settle(unsubscribe, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
|
||||
const controller = this.controllers.get(serverId);
|
||||
if (!controller) {
|
||||
@@ -1594,18 +1739,39 @@ export class HostRuntimeStore {
|
||||
};
|
||||
}
|
||||
|
||||
getEarliestOnlineHostServerId(): string | null {
|
||||
let earliestServerId: string | null = null;
|
||||
let earliestOnlineAt: string | null = null;
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (!isHostRuntimeConnected(snapshot) || !snapshot?.lastOnlineAt) continue;
|
||||
if (!earliestOnlineAt || snapshot.lastOnlineAt < earliestOnlineAt) {
|
||||
earliestOnlineAt = snapshot.lastOnlineAt;
|
||||
earliestServerId = host.serverId;
|
||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const isAnyOnline = (): boolean => {
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (snapshot?.connectionStatus === "online") return true;
|
||||
}
|
||||
}
|
||||
return earliestServerId;
|
||||
return false;
|
||||
};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
if (isAnyOnline()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (isAnyOnline()) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ensureConnectedAll(): void {
|
||||
|
||||
@@ -97,7 +97,11 @@ const {
|
||||
|
||||
const hoistedMockClient = {
|
||||
isConnected: true,
|
||||
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
|
||||
getCheckoutStatus: vi.fn(
|
||||
async (): Promise<{ currentBranch: string | null }> => ({
|
||||
currentBranch: "main",
|
||||
}),
|
||||
),
|
||||
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
|
||||
searchGitHub: vi.fn(async () => ({
|
||||
items: [hoistedPrItem, hoistedPrItemB],
|
||||
@@ -672,7 +676,7 @@ describe("NewWorkspaceScreen picker payload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no new fields when nothing is selected (default)", async () => {
|
||||
it("uses the loaded current branch as the selected branch", async () => {
|
||||
renderScreen();
|
||||
await flush();
|
||||
|
||||
@@ -684,12 +688,24 @@ describe("NewWorkspaceScreen picker payload", () => {
|
||||
expect(call).toMatchObject({
|
||||
cwd: "/repo",
|
||||
worktreeSlug: "gentle-slug",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
expect(call).not.toHaveProperty("refName");
|
||||
expect(call).not.toHaveProperty("action");
|
||||
expect(call).not.toHaveProperty("githubPrNumber");
|
||||
});
|
||||
|
||||
it("requires a branch selection when the current branch is unavailable", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValueOnce({ currentBranch: null });
|
||||
renderScreen();
|
||||
await flush();
|
||||
|
||||
click(await findByTestId("test-composer-submit"));
|
||||
await flush();
|
||||
|
||||
expect(mockClient.createPaseoWorktree).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Choose a branch to start from");
|
||||
});
|
||||
|
||||
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
|
||||
initialDraftState.text = "please review this change";
|
||||
renderScreen();
|
||||
|
||||
@@ -194,6 +194,15 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
||||
}
|
||||
|
||||
function resolveSelectedPickerItem(input: {
|
||||
pickerSelection: PickerSelection | null;
|
||||
currentBranch: string | null | undefined;
|
||||
}): PickerItem | null {
|
||||
if (input.pickerSelection) return input.pickerSelection.item;
|
||||
const currentBranch = input.currentBranch?.trim();
|
||||
return currentBranch ? { kind: "branch", name: currentBranch } : null;
|
||||
}
|
||||
|
||||
function syncPickerPrAttachment(input: {
|
||||
attachments: ComposerAttachment[];
|
||||
previousPickerPrNumber: number | null;
|
||||
@@ -436,7 +445,6 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
const selectedItem = pickerSelection?.item ?? null;
|
||||
const isPending = pendingAction !== null;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -476,7 +484,10 @@ export function NewWorkspaceScreen({
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||
const selectedItem = resolveSelectedPickerItem({
|
||||
pickerSelection,
|
||||
currentBranch: checkoutStatusQuery.data?.currentBranch,
|
||||
});
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branch-suggestions", serverId, sourceDirectory, debouncedPickerSearchQuery],
|
||||
@@ -527,8 +538,8 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedItem) return pickerItemTriggerLabel(selectedItem);
|
||||
return currentBranch ?? "main";
|
||||
}, [currentBranch, selectedItem]);
|
||||
return "Select branch";
|
||||
}, [selectedItem]);
|
||||
|
||||
const selectedOptionId = useMemo(() => {
|
||||
if (!selectedItem) return "";
|
||||
@@ -588,6 +599,9 @@ export function NewWorkspaceScreen({
|
||||
const buildCreateWorktreeInput = useCallback(
|
||||
(input: { cwd: string; attachments: AgentAttachment[] }) => {
|
||||
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
if (!checkoutRequest) {
|
||||
throw new Error("Choose a branch to start from");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: input.cwd,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* @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 type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
|
||||
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
|
||||
|
||||
const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
iconSize: { sm: 14, md: 20 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" as const, medium: "500" as const },
|
||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
surfaceSidebarHover: "#1a1a1a",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#444",
|
||||
accent: "#0a84ff",
|
||||
palette: { red: { 300: "#ff6b6b" } },
|
||||
},
|
||||
},
|
||||
projectsState: {
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
} as UseProjectsResult,
|
||||
},
|
||||
navigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
const passthrough = ({
|
||||
children,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityRole,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
...rest
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
accessibilityRole?: string;
|
||||
onPress?: (event: { stopPropagation: () => void }) => void;
|
||||
onHoverIn?: () => void;
|
||||
onHoverOut?: () => void;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataAttrs: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (key.startsWith("data-")) {
|
||||
dataAttrs[key] = value;
|
||||
}
|
||||
}
|
||||
return React.createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: onPress
|
||||
? (event: React.MouseEvent) => {
|
||||
onPress({ stopPropagation: () => event.stopPropagation() });
|
||||
}
|
||||
: undefined,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
...dataAttrs,
|
||||
},
|
||||
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
Pressable: passthrough,
|
||||
Image: ({ source }: { source?: { uri?: string } }) =>
|
||||
React.createElement("img", { src: source?.uri ?? "" }),
|
||||
Platform: { OS: "web" },
|
||||
};
|
||||
});
|
||||
|
||||
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 icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
MoreVertical: icon("MoreVertical"),
|
||||
ExternalLink: icon("ExternalLink"),
|
||||
Pencil: icon("Pencil"),
|
||||
FolderGit2: icon("FolderGit2"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate },
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: ({ size }: { size?: string | number }) =>
|
||||
React.createElement("span", {
|
||||
"data-testid": "projects-loading-spinner",
|
||||
"data-size": size,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu" }, children),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean; open: boolean }) => React.ReactNode);
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
},
|
||||
typeof children === "function"
|
||||
? children({ pressed: false, hovered: false, open: false })
|
||||
: children,
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "dropdown-menu-content" }, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
"data-testid": testID,
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onSelect?.();
|
||||
},
|
||||
},
|
||||
children,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-projects", () => ({
|
||||
useProjects: () => projectsState.current,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
import ProjectsScreen from "./projects-screen";
|
||||
|
||||
function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceSummary {
|
||||
return {
|
||||
id: "ws-1",
|
||||
name: "main",
|
||||
workspaceKind: "directory",
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
serverName: "alpha",
|
||||
isOnline: true,
|
||||
repoRoot: "/home/me/proj",
|
||||
workspaceCount: 1,
|
||||
workspaces: [workspaceSummary()],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
const hosts = overrides.hosts ?? [hostEntry()];
|
||||
const totalWorkspaceCount =
|
||||
overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0);
|
||||
const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length;
|
||||
return {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
hosts,
|
||||
totalWorkspaceCount,
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function findRow(container: HTMLElement, projectKey: string): HTMLElement {
|
||||
const row = container.querySelector<HTMLElement>(`[data-testid="project-row-${projectKey}"]`);
|
||||
if (!row) throw new Error(`Expected row for ${projectKey}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("ProjectsScreen", () => {
|
||||
let container: HTMLElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
setProjectsState({});
|
||||
navigate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) {
|
||||
act(() => {
|
||||
root?.render(<ProjectsScreen view={view} />);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders a row whose visible content is the project name only", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
projectName: "acme/app",
|
||||
hosts: [hostEntry({ serverName: "alpha", workspaceCount: 5 })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const rows = container?.querySelectorAll('[data-testid^="project-row-"]') ?? [];
|
||||
expect(rows.length).toBe(1);
|
||||
expect(container?.textContent).toContain("acme/app");
|
||||
expect(container?.textContent).not.toContain("workspace");
|
||||
expect(container?.textContent).not.toContain("offline");
|
||||
expect(container?.textContent).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("navigates to the project detail route when the row is pressed", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const row = findRow(container!, "remote:github.com/acme/app");
|
||||
act(() => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp");
|
||||
});
|
||||
|
||||
it("does not render a kebab menu on the row", () => {
|
||||
setProjectsState({
|
||||
projects: [project({ projectKey: "remote:github.com/acme/app" })],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(
|
||||
container?.querySelector('[data-testid="project-row-menu-remote:github.com/acme/app"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a centered loading spinner before the first response", () => {
|
||||
setProjectsState({ isLoading: true, projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.querySelector('[data-testid="projects-loading-spinner"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects yet");
|
||||
});
|
||||
|
||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||
const hostErrors: ProjectHostError[] = [
|
||||
{ serverId: "a", serverName: "alpha", message: "timed out" },
|
||||
{ serverId: "b", serverName: "beta", message: "unreachable" },
|
||||
];
|
||||
setProjectsState({
|
||||
projects: [project()],
|
||||
hostErrors,
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const banner = container?.querySelector('[data-testid="projects-host-errors"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.textContent).toContain("alpha");
|
||||
expect(banner?.textContent).toContain("beta");
|
||||
expect(container?.querySelector('[data-testid^="project-row-"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("highlights the selected row when the active view targets a project", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({ projectKey: "remote:github.com/acme/app" }),
|
||||
project({
|
||||
projectKey: "remote:github.com/acme/other",
|
||||
projectName: "acme/other",
|
||||
githubUrl: "https://github.com/acme/other",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render({ kind: "project", projectKey: "remote:github.com/acme/app" });
|
||||
|
||||
const selected = findRow(container!, "remote:github.com/acme/app");
|
||||
const other = findRow(container!, "remote:github.com/acme/other");
|
||||
expect(selected.getAttribute("data-selected")).toBe("true");
|
||||
expect(other.getAttribute("data-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not include the word 'checkout' anywhere in the rendered tree", () => {
|
||||
setProjectsState({
|
||||
projects: [
|
||||
project({
|
||||
hosts: [
|
||||
hostEntry({ serverId: "a", serverName: "alpha", workspaceCount: 3 }),
|
||||
hostEntry({ serverId: "b", serverName: "beta", workspaceCount: 2 }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
hostErrors: [{ serverId: "x", serverName: "x", message: "down" }],
|
||||
});
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
const html = container?.innerHTML.toLowerCase() ?? "";
|
||||
expect(html).not.toContain("checkout");
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects";
|
||||
|
||||
interface ProjectsScreenProps {
|
||||
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="projects-list">
|
||||
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
|
||||
<View style={settingsStyles.card}>
|
||||
{projects.map((project, index) => (
|
||||
<ProjectRow
|
||||
key={project.projectKey}
|
||||
project={project}
|
||||
isFirst={index === 0}
|
||||
isSelected={selectedProjectKey === project.projectKey}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
|
||||
return (
|
||||
<View style={styles.errorsBanner} testID="projects-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`Couldn't load projects from host ${error.serverName}: ${error.message}`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectRowProps {
|
||||
project: ProjectSummary;
|
||||
isFirst: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { hosts, projectKey, projectName } = project;
|
||||
const leadingHost = hosts[0];
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
router.navigate(buildProjectSettingsRoute(projectKey));
|
||||
}, [projectKey]);
|
||||
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
hovered && !isSelected && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
[isFirst, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handleNavigate}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${projectName}`}
|
||||
testID={`project-row-${projectKey}`}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.leading}>
|
||||
<ProjectRowIcon host={leadingHost} projectName={projectName} />
|
||||
</View>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRowIcon({
|
||||
host,
|
||||
projectName,
|
||||
}: {
|
||||
host: ProjectHostEntry | undefined;
|
||||
projectName: string;
|
||||
}) {
|
||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||
const { icon } = useProjectIconQuery({
|
||||
serverId: host?.serverId ?? "",
|
||||
cwd: host?.repoRoot ?? "",
|
||||
});
|
||||
const iconDataUri =
|
||||
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
|
||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={styles.iconImage} />;
|
||||
}
|
||||
return (
|
||||
<View style={styles.iconFallback}>
|
||||
<Text style={styles.iconFallbackText}>{initial}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
marginBottom: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
rowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
leading: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconImage: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
iconFallback: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconFallbackText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
spinnerColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -27,20 +27,13 @@ import {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Plus,
|
||||
FolderGit2,
|
||||
} from "lucide-react-native";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import {
|
||||
useAppSettings,
|
||||
useSettings,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
@@ -71,14 +64,11 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type SettingsSectionSlug,
|
||||
@@ -92,9 +82,7 @@ import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-ac
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
| { kind: "host"; serverId: string };
|
||||
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
@@ -366,7 +354,7 @@ function getUpdateButtonLabel(
|
||||
}
|
||||
|
||||
function DesktopAppUpdateRow() {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const {
|
||||
isDesktopApp,
|
||||
statusText,
|
||||
@@ -396,7 +384,7 @@ function DesktopAppUpdateRow() {
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
const handleReleaseChannelChange = useCallback(
|
||||
(releaseChannel: EffectiveSettings["releaseChannel"]) => {
|
||||
(releaseChannel: AppSettings["releaseChannel"]) => {
|
||||
void updateSettings({ releaseChannel });
|
||||
},
|
||||
[updateSettings],
|
||||
@@ -550,37 +538,6 @@ function SidebarSectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProjectsButtonProps {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
[isSelected, theme.colors.foreground],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onSelect}
|
||||
testID="settings-projects"
|
||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||
>
|
||||
<FolderGit2
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
Projects
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostItemProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
@@ -627,7 +584,6 @@ interface SettingsSidebarProps {
|
||||
view: SettingsView;
|
||||
onSelectSection: (section: SettingsSectionSlug) => void;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onSelectProjects: () => void;
|
||||
onAddHost: () => void;
|
||||
onBackToWorkspace: () => void;
|
||||
layout: "desktop" | "mobile";
|
||||
@@ -637,7 +593,6 @@ function SettingsSidebar({
|
||||
view,
|
||||
onSelectSection,
|
||||
onSelectHost,
|
||||
onSelectProjects,
|
||||
onAddHost,
|
||||
onBackToWorkspace,
|
||||
layout,
|
||||
@@ -665,7 +620,6 @@ function SettingsSidebar({
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
return (
|
||||
@@ -695,7 +649,6 @@ function SettingsSidebar({
|
||||
onSelect={onSelectSection}
|
||||
/>
|
||||
))}
|
||||
<SidebarProjectsButton isSelected={isProjectsSelected} onSelect={onSelectProjects} />
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
@@ -859,15 +812,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleSelectProjects = useCallback(() => {
|
||||
const target = buildProjectsSettingsRoute();
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [isCompactLayout, router]);
|
||||
|
||||
const handleScanQr = useCallback(() => {
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
@@ -894,11 +838,17 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}, [router]);
|
||||
|
||||
const handleBackToWorkspace = useCallback(() => {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
if (!isCompactLayout) {
|
||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
||||
if (lastWorkspaceRoute) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (anyOnlineServerId) {
|
||||
@@ -906,7 +856,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [anyOnlineServerId, router]);
|
||||
}, [anyOnlineServerId, isCompactLayout, router]);
|
||||
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
@@ -927,9 +877,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "project" || view.kind === "projects") {
|
||||
return { title: "Projects", Icon: FolderGit2 };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
@@ -937,12 +884,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
if (view.kind === "host") {
|
||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
}
|
||||
if (view.kind === "projects") {
|
||||
return <ProjectsScreen view={view} />;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return <ProjectSettingsScreen projectKey={view.projectKey} />;
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
@@ -1017,7 +958,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="mobile"
|
||||
@@ -1028,18 +968,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile detail: full-screen content with a back header. Project detail uses
|
||||
// an app-level back (out of settings, to the workspace) since the in-body
|
||||
// "Back to projects" ghost button handles list-level back; other detail views
|
||||
// step back to the settings root.
|
||||
const detailBackHandler = view.kind === "project" ? handleBackToWorkspace : handleBackToRoot;
|
||||
// Mobile detail: full-screen content with a back header that returns to the list.
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title={detailHeader?.title}
|
||||
titleAccessory={detailHeader?.titleAccessory}
|
||||
onBack={detailBackHandler}
|
||||
onBack={handleBackToRoot}
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
@@ -1059,7 +995,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
layout="desktop"
|
||||
@@ -1153,16 +1088,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
placeholderText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopStyles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -17,7 +17,7 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
@@ -29,6 +29,11 @@ import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section
|
||||
const RESTART_CONFIRMATION_MESSAGE =
|
||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||
|
||||
const INJECT_TOOLS_OPTIONS = [
|
||||
{ value: "on", label: "On" },
|
||||
{ value: "off", label: "Off" },
|
||||
];
|
||||
|
||||
function formatHostConnectionLabel(connection: HostConnection): string {
|
||||
if (connection.type === "relay") {
|
||||
return `Relay (${connection.relayEndpoint})`;
|
||||
@@ -574,7 +579,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Restart daemon</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Restarts the daemon process. The app will reconnect automatically
|
||||
Restarts the daemon process. The app will reconnect automatically.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
@@ -597,10 +602,10 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
(value: string) => {
|
||||
void patchConfig({
|
||||
mcp: {
|
||||
injectIntoAgents: next,
|
||||
injectIntoAgents: value === "on",
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -618,10 +623,11 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
Automatically inject Paseo MCP tools into new agents
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.mcp.injectIntoAgents !== false}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
|
||||
onValueChange={handleValueChange}
|
||||
accessibilityLabel="Inject Paseo tools"
|
||||
options={INJECT_TOOLS_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
/**
|
||||
* @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 type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { MutableDaemonConfig } from "@server/shared/messages";
|
||||
|
||||
const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
iconSize: { sm: 14, md: 20 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400" },
|
||||
borderRadius: { lg: 8 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
surface1: "#111",
|
||||
surface2: "#222",
|
||||
surface3: "#333",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
accent: "#0a84ff",
|
||||
statusSuccess: "#00ff00",
|
||||
statusWarning: "#ff9500",
|
||||
statusDanger: "#ff0000",
|
||||
palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
|
||||
},
|
||||
},
|
||||
snapshotState: {
|
||||
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
},
|
||||
configState: {
|
||||
config: null as MutableDaemonConfig | null,
|
||||
},
|
||||
patchConfigMock: vi.fn(async () => undefined),
|
||||
refreshMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => ({
|
||||
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||
React.createElement("div", { "data-testid": testID }, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children),
|
||||
Pressable: ({
|
||||
children,
|
||||
onPress,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
disabled,
|
||||
testID,
|
||||
}: {
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((state: { pressed: boolean; hovered: boolean }) => React.ReactNode);
|
||||
onPress?: (event: React.MouseEvent) => void;
|
||||
onHoverIn?: () => void;
|
||||
onHoverOut?: () => void;
|
||||
accessibilityRole?: string;
|
||||
accessibilityLabel?: string;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
role: accessibilityRole,
|
||||
"aria-label": accessibilityLabel,
|
||||
"aria-disabled": disabled ? "true" : undefined,
|
||||
"data-testid": testID,
|
||||
onClick: disabled ? undefined : onPress,
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
},
|
||||
typeof children === "function" ? children({ pressed: false, hovered: false }) : children,
|
||||
),
|
||||
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity-indicator" }),
|
||||
}));
|
||||
|
||||
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 icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
||||
return {
|
||||
ChevronRight: icon("ChevronRight"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/switch", () => ({
|
||||
Switch: ({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: {
|
||||
value: boolean;
|
||||
onValueChange?: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement("div", {
|
||||
role: "switch",
|
||||
"aria-checked": value ? "true" : "false",
|
||||
"aria-disabled": disabled ? "true" : undefined,
|
||||
"aria-label": accessibilityLabel,
|
||||
"data-testid": testID ?? "provider-switch",
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (disabled) return;
|
||||
onValueChange?.(!value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: (provider: string) => () =>
|
||||
React.createElement("span", { "data-icon": `provider-${provider}` }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-diagnostic-sheet", () => ({
|
||||
ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
|
||||
React.createElement("div", {
|
||||
"data-testid": "provider-diagnostic-sheet",
|
||||
"data-provider": provider,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries: snapshotState.entries,
|
||||
isLoading: snapshotState.isLoading,
|
||||
isFetching: false,
|
||||
isRefreshing: snapshotState.isRefreshing,
|
||||
error: null,
|
||||
supportsSnapshot: true,
|
||||
refresh: refreshMock,
|
||||
refetchIfStale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-daemon-config", () => ({
|
||||
useDaemonConfig: () => ({
|
||||
config: configState.config,
|
||||
isLoading: false,
|
||||
patchConfig: patchConfigMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
}));
|
||||
|
||||
import { ProvidersSection } from "./providers-section";
|
||||
|
||||
const claudeEntry: ProviderSnapshotEntry = {
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: "Claude",
|
||||
description: "Claude Code",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
models: [
|
||||
{ provider: "claude", id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ provider: "claude", id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ provider: "claude", id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
||||
],
|
||||
};
|
||||
|
||||
const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Codex",
|
||||
description: "OpenAI Codex",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return { mcp: { injectIntoAgents: false }, providers };
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
return Array.from(el.querySelectorAll<HTMLElement>("*"));
|
||||
}
|
||||
|
||||
function indexOfMatches(nodes: HTMLElement[], selector: string): number {
|
||||
return nodes.findIndex((node) => node.matches(selector));
|
||||
}
|
||||
|
||||
function indexOfText(nodes: HTMLElement[], text: string): number {
|
||||
return nodes.findIndex((node) => node.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
describe("ProvidersSection", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
snapshotState.entries = undefined;
|
||||
snapshotState.isLoading = false;
|
||||
snapshotState.isRefreshing = false;
|
||||
configState.config = null;
|
||||
patchConfigMock.mockReset();
|
||||
patchConfigMock.mockResolvedValue(undefined);
|
||||
refreshMock.mockReset();
|
||||
refreshMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
act(() => {
|
||||
root?.render(<ProvidersSection serverId="server-1" />);
|
||||
});
|
||||
}
|
||||
|
||||
function findRow(accessibilityLabel: string): HTMLElement {
|
||||
const row = container?.querySelector<HTMLElement>(
|
||||
`[role="button"][aria-label="${accessibilityLabel}"]`,
|
||||
);
|
||||
if (!row) throw new Error(`Expected row with aria-label "${accessibilityLabel}"`);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("renders the disabled provider with its server-provided label in snapshot order", () => {
|
||||
snapshotState.entries = [claudeEntry, disabledCodexEntry];
|
||||
configState.config = makeConfig({ codex: { enabled: false } });
|
||||
|
||||
render();
|
||||
|
||||
const rows = Array.from(
|
||||
container?.querySelectorAll<HTMLElement>('[role="button"][aria-label$="provider details"]') ??
|
||||
[],
|
||||
);
|
||||
expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([
|
||||
"Claude provider details",
|
||||
"Codex provider details",
|
||||
]);
|
||||
|
||||
const codexRow = findRow("Codex provider details");
|
||||
const codexNodes = descendants(codexRow);
|
||||
expect(indexOfText(codexNodes, "Codex")).toBeGreaterThanOrEqual(0);
|
||||
expect(indexOfText(codexNodes, "codex")).toBe(-1);
|
||||
expect(indexOfText(codexNodes, "Disabled")).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("composes the row as chevron, icon, label, status, model count, then switch", () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Claude provider details");
|
||||
const nodes = descendants(row);
|
||||
const chevron = indexOfMatches(nodes, '[data-icon="ChevronRight"]');
|
||||
const icon = indexOfMatches(nodes, '[data-icon="provider-claude"]');
|
||||
const label = indexOfText(nodes, "Claude");
|
||||
const status = indexOfText(nodes, "Available");
|
||||
const modelCount = indexOfText(nodes, "3 models");
|
||||
const switchEl = indexOfMatches(nodes, '[role="switch"]');
|
||||
|
||||
expect(chevron).toBeGreaterThanOrEqual(0);
|
||||
expect(icon).toBeGreaterThan(chevron);
|
||||
expect(label).toBeGreaterThan(icon);
|
||||
expect(status).toBeGreaterThan(label);
|
||||
expect(modelCount).toBeGreaterThan(status);
|
||||
expect(switchEl).toBeGreaterThan(modelCount);
|
||||
});
|
||||
|
||||
it("opens the diagnostic sheet when the outer row is pressed for a disabled provider", () => {
|
||||
snapshotState.entries = [disabledCodexEntry];
|
||||
configState.config = makeConfig({ codex: { enabled: false } });
|
||||
|
||||
render();
|
||||
|
||||
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
||||
|
||||
const row = findRow("Codex provider details");
|
||||
act(() => {
|
||||
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet?.getAttribute("data-provider")).toBe("codex");
|
||||
});
|
||||
|
||||
it("toggles the provider enabled flag through patchConfig when the switch is pressed", () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
|
||||
render();
|
||||
|
||||
const row = findRow("Claude provider details");
|
||||
const switchEl = row.querySelector<HTMLElement>('[role="switch"]');
|
||||
expect(switchEl).not.toBeNull();
|
||||
expect(switchEl?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
switchEl?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(patchConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(patchConfigMock).toHaveBeenCalledWith({
|
||||
providers: { claude: { enabled: false } },
|
||||
});
|
||||
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,173 +1,80 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { ChevronRight, RotateCw } from "lucide-react-native";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
|
||||
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
||||
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
||||
|
||||
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
|
||||
|
||||
interface ProviderStatus {
|
||||
tone: StatusTone;
|
||||
label: string;
|
||||
modelCount: number | null;
|
||||
function getProviderStatusLabel(status: string): string {
|
||||
if (status === "ready") return "Available";
|
||||
if (status === "error") return "Error";
|
||||
if (status === "loading") return "Loading...";
|
||||
return "Not installed";
|
||||
}
|
||||
|
||||
function getProviderStatus(status: string, enabled: boolean, modelCount: number): ProviderStatus {
|
||||
if (!enabled) return { tone: "muted", label: "Disabled", modelCount: null };
|
||||
if (status === "loading") return { tone: "loading", label: "Loading", modelCount: null };
|
||||
if (status === "error") return { tone: "danger", label: "Error", modelCount: null };
|
||||
if (status === "ready") {
|
||||
return {
|
||||
tone: "success",
|
||||
label: "Available",
|
||||
modelCount: modelCount > 0 ? modelCount : null,
|
||||
};
|
||||
}
|
||||
return { tone: "warning", label: "Not installed", modelCount: null };
|
||||
function getProviderStatusVariant(status: string): "success" | "error" | "muted" {
|
||||
if (status === "ready") return "success";
|
||||
if (status === "error") return "error";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
def: ProviderDefinition;
|
||||
entry: ProviderEntry;
|
||||
enabled: boolean;
|
||||
isToggling: boolean;
|
||||
entry: ProviderEntry | undefined;
|
||||
isFirst: boolean;
|
||||
onPress: (providerId: string) => void;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
def,
|
||||
entry,
|
||||
enabled,
|
||||
isToggling,
|
||||
isFirst,
|
||||
onPress,
|
||||
onToggleEnabled,
|
||||
}: ProviderRowProps) {
|
||||
function ProviderRow({ def, entry, isFirst, onPress }: ProviderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const providerError =
|
||||
enabled &&
|
||||
entry.status === "error" &&
|
||||
typeof entry.error === "string" &&
|
||||
entry.error.trim().length > 0
|
||||
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
|
||||
? entry.error.trim()
|
||||
: null;
|
||||
const modelCount = entry.models?.length ?? 0;
|
||||
const providerStatus = getProviderStatus(entry.status, enabled, modelCount);
|
||||
const modelCount = entry?.models?.length ?? 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(def.id);
|
||||
}, [def.id, onPress]);
|
||||
const handleToggleValueChange = useCallback(
|
||||
(value: boolean) => {
|
||||
onToggleEnabled(def.id, value);
|
||||
},
|
||||
[def.id, onToggleEnabled],
|
||||
);
|
||||
const rowStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
!isFirst && settingsStyles.rowBorder,
|
||||
styles.row,
|
||||
hovered && styles.rowHovered,
|
||||
pressed && styles.rowPressed,
|
||||
],
|
||||
const handlePress = useCallback(() => onPress(def.id), [def.id, onPress]);
|
||||
|
||||
const rowStyle = useMemo(
|
||||
() => [settingsStyles.row, !isFirst && settingsStyles.rowBorder],
|
||||
[isFirst],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${def.label} provider details`}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={styles.rowContent}>
|
||||
<ChevronRight
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<View style={styles.textColumn}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{def.label}
|
||||
</Text>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<StatusIndicator status={providerStatus} />
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={enabled}
|
||||
onValueChange={handleToggleValueChange}
|
||||
disabled={isToggling}
|
||||
accessibilityLabel={`Enable ${def.label}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
switch (tone) {
|
||||
case "success":
|
||||
return theme.colors.statusSuccess;
|
||||
case "warning":
|
||||
return theme.colors.statusWarning;
|
||||
case "danger":
|
||||
return theme.colors.statusDanger;
|
||||
default:
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: ProviderStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
const dotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
|
||||
[status.tone, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
{status.tone === "loading" ? (
|
||||
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={dotStyle} />
|
||||
)}
|
||||
<Text style={styles.statusLabel}>{status.label}</Text>
|
||||
{status.modelCount !== null ? (
|
||||
<>
|
||||
<Text style={styles.separator}>·</Text>
|
||||
<Text style={styles.statusLabel}>
|
||||
{status.modelCount === 1 ? "1 model" : `${status.modelCount} models`}
|
||||
<Pressable style={rowStyle} onPress={handlePress} accessibilityRole="button">
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
|
||||
</View>
|
||||
{providerError ? (
|
||||
<Text style={styles.errorText} numberOfLines={3}>
|
||||
{providerError}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{status === "ready" && modelCount > 0 ? (
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{modelCount === 1 ? "1 model" : `${modelCount} models`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<StatusBadge
|
||||
label={getProviderStatusLabel(status)}
|
||||
variant={getProviderStatusVariant(status)}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,11 +86,8 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
const providerRefreshInFlight =
|
||||
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
|
||||
const hasServer = serverId.length > 0;
|
||||
@@ -193,22 +97,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
}, [refresh]);
|
||||
|
||||
const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (providerId: string, enabled: boolean) => {
|
||||
setPendingProviderId(providerId);
|
||||
try {
|
||||
await patchConfig({ providers: { [providerId]: { enabled } } });
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
"Unable to update provider",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setPendingProviderId((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[patchConfig],
|
||||
);
|
||||
|
||||
const refreshAction = useMemo(
|
||||
() =>
|
||||
@@ -258,24 +146,17 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
<Text style={styles.emptyText}>Loading...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
||||
{hasServer && isConnected && !isLoading ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const entry = entries?.find((candidate) => candidate.provider === def.id);
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isFirst={index === 0}
|
||||
onPress={setDiagnosticProvider}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{providerDefinitions.map((def, index) => (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
entry={entries?.find((e) => e.provider === def.id)}
|
||||
isFirst={index === 0}
|
||||
onPress={setDiagnosticProvider}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
@@ -304,49 +185,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[3],
|
||||
minHeight: 56,
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
textColumn: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
separator: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
interface StartupSplashScreenProps {
|
||||
bootstrapState?: {
|
||||
splashError: string | null;
|
||||
phase: "starting-daemon" | "connecting" | "online" | "error";
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
};
|
||||
}
|
||||
@@ -32,10 +23,6 @@ interface StartupSplashScreenProps {
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const DOCS_URL = "https://paseo.sh/docs";
|
||||
|
||||
const LOGO_SIZE = 96;
|
||||
const SHIMMER_PEAK_WIDTH = 120;
|
||||
const SHIMMER_DURATION_MS = 1800;
|
||||
|
||||
function openGithubIssue(): void {
|
||||
void openExternalUrl(GITHUB_ISSUE_URL);
|
||||
}
|
||||
@@ -44,141 +31,6 @@ function openDocs(): void {
|
||||
void openExternalUrl(DOCS_URL);
|
||||
}
|
||||
|
||||
const WEB_SPLASH_SHIMMER_KEYFRAME_ID = "paseo-splash-shimmer-keyframes";
|
||||
const WEB_SPLASH_SHIMMER_ANIMATION_NAME = "paseo-splash-shimmer";
|
||||
|
||||
const WEB_SPLASH_SHIMMER_KEYFRAME_CSS = `
|
||||
@keyframes ${WEB_SPLASH_SHIMMER_ANIMATION_NAME} {
|
||||
0% {
|
||||
background-position: -${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: ${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let webSplashShimmerRegistered = false;
|
||||
|
||||
function ensureWebSplashShimmerKeyframes() {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
if (webSplashShimmerRegistered) {
|
||||
return;
|
||||
}
|
||||
const existing = document.getElementById(WEB_SPLASH_SHIMMER_KEYFRAME_ID);
|
||||
if (existing) {
|
||||
webSplashShimmerRegistered = true;
|
||||
return;
|
||||
}
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = WEB_SPLASH_SHIMMER_KEYFRAME_ID;
|
||||
styleElement.textContent = WEB_SPLASH_SHIMMER_KEYFRAME_CSS;
|
||||
document.head.appendChild(styleElement);
|
||||
webSplashShimmerRegistered = true;
|
||||
}
|
||||
|
||||
function LogoShimmer() {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (isWeb) {
|
||||
return <WebLogoShimmer color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
return <NativeLogoShimmer color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
function WebLogoShimmer({ color }: { color: string }) {
|
||||
useEffect(() => {
|
||||
ensureWebSplashShimmerKeyframes();
|
||||
}, []);
|
||||
|
||||
const shimmerStyle = useMemo(
|
||||
() => ({
|
||||
width: LOGO_SIZE,
|
||||
height: LOGO_SIZE,
|
||||
WebkitMaskImage: `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' width='${LOGO_SIZE}' height='${LOGO_SIZE}' viewBox='0 0 700 700'><path fill='black' d='M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z'/></svg>`)}")`,
|
||||
WebkitMaskSize: "contain",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
background: `linear-gradient(90deg, ${color} 0%, ${color}88 40%, ${color}FF 50%, ${color}88 60%, ${color} 100%)`,
|
||||
backgroundSize: `${LOGO_SIZE + SHIMMER_PEAK_WIDTH * 2}px ${LOGO_SIZE}px`,
|
||||
animationName: WEB_SPLASH_SHIMMER_ANIMATION_NAME,
|
||||
animationDuration: `${SHIMMER_DURATION_MS}ms`,
|
||||
animationTimingFunction: "linear",
|
||||
animationIterationCount: "infinite",
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
return <View style={shimmerStyle as never} />;
|
||||
}
|
||||
|
||||
function NativeLogoShimmer({ color }: { color: string }) {
|
||||
const shimmerTranslateX = useSharedValue(-SHIMMER_PEAK_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
shimmerTranslateX.value = -SHIMMER_PEAK_WIDTH;
|
||||
shimmerTranslateX.value = withRepeat(
|
||||
withTiming(LOGO_SIZE + SHIMMER_PEAK_WIDTH, {
|
||||
duration: SHIMMER_DURATION_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return () => {
|
||||
cancelAnimation(shimmerTranslateX);
|
||||
};
|
||||
}, [shimmerTranslateX]);
|
||||
|
||||
const peakStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: shimmerTranslateX.value }],
|
||||
}));
|
||||
|
||||
const trackStyle = useMemo(
|
||||
() => [styles.nativeShimmerTrack, { width: LOGO_SIZE, height: LOGO_SIZE }],
|
||||
[],
|
||||
);
|
||||
|
||||
const peakCombinedStyle = useMemo(
|
||||
() => [styles.nativeShimmerPeak, peakStyle, { width: SHIMMER_PEAK_WIDTH, height: LOGO_SIZE }],
|
||||
[peakStyle],
|
||||
);
|
||||
|
||||
const maskElement = useMemo(
|
||||
() => (
|
||||
<View style={styles.shimmerMask}>
|
||||
<PaseoLogo size={LOGO_SIZE} color="#000000" />
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<MaskedView style={trackStyle} maskElement={maskElement}>
|
||||
<View style={trackStyle}>
|
||||
<View style={styles.nativeShimmerBase}>
|
||||
<PaseoLogo size={LOGO_SIZE} color={color} />
|
||||
</View>
|
||||
<Animated.View style={peakCombinedStyle}>
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id="splashShimmer" x1="0" y1="0" x2="1" y2="0">
|
||||
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
<Stop offset="0.5" stopColor="#FFFFFF" stopOpacity="0.4" />
|
||||
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill="url(#splashShimmer)" />
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</MaskedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "relative",
|
||||
@@ -189,6 +41,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[8],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
containerError: {
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
errorScreen: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
@@ -212,6 +68,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[8],
|
||||
paddingTop: theme.spacing[16],
|
||||
},
|
||||
centeredContent: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
maxWidth: 520,
|
||||
width: "100%",
|
||||
},
|
||||
errorContent: {
|
||||
alignItems: "stretch",
|
||||
maxWidth: 720,
|
||||
@@ -222,11 +84,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
title: {
|
||||
marginTop: theme.spacing[8],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize["3xl"],
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textAlign: "center",
|
||||
},
|
||||
titleError: {
|
||||
textAlign: "left",
|
||||
},
|
||||
subtitleRow: {
|
||||
marginTop: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
progressSteps: {
|
||||
marginTop: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
width: "100%",
|
||||
},
|
||||
progressStepRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: theme.spacing[8],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.lg,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitleInline: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.lg,
|
||||
textAlign: "center",
|
||||
},
|
||||
errorDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -273,29 +167,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
shimmerMask: {
|
||||
width: LOGO_SIZE,
|
||||
height: LOGO_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
nativeShimmerTrack: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
nativeShimmerBase: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
nativeShimmerPeak: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const TITLE_ERROR_STYLE = [styles.title, styles.titleError];
|
||||
|
||||
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
@@ -311,7 +186,9 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
const [logsError, setLogsError] = useState<string | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
|
||||
const isError = bootstrapState !== undefined && bootstrapState.splashError !== null;
|
||||
const phase = bootstrapState?.phase;
|
||||
const isError = phase === "error";
|
||||
const isSimpleSplash = bootstrapState === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isError) {
|
||||
@@ -352,6 +229,23 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
};
|
||||
}, [isError]);
|
||||
|
||||
let progressSteps: { key: string; label: string; status: "active" | "complete" }[];
|
||||
if (phase === "starting-daemon") {
|
||||
progressSteps = [
|
||||
{ key: "starting-daemon", label: "Starting local server...", status: "active" },
|
||||
];
|
||||
} else if (phase === "connecting") {
|
||||
progressSteps = [
|
||||
{ key: "starting-daemon", label: "Started local server", status: "complete" },
|
||||
{ key: "connecting", label: "Connecting to local server...", status: "active" },
|
||||
];
|
||||
} else {
|
||||
progressSteps = [
|
||||
{ key: "starting-daemon", label: "Started local server", status: "complete" },
|
||||
{ key: "connecting", label: "Connected to local server", status: "complete" },
|
||||
];
|
||||
}
|
||||
|
||||
const logsText = useMemo(() => {
|
||||
if (isLoadingLogs) {
|
||||
return "Loading daemon logs...";
|
||||
@@ -389,11 +283,36 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
[theme.colors.palette.white],
|
||||
);
|
||||
|
||||
if (isSimpleSplash) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.subtitle}>Connecting…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isError) {
|
||||
return (
|
||||
<View testID="startup-splash" style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<TitlebarDragRegion />
|
||||
<LogoShimmer />
|
||||
<View style={styles.centeredContent}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<View style={styles.progressSteps}>
|
||||
{progressSteps.map((step) => (
|
||||
<View key={step.key} style={styles.progressStepRow}>
|
||||
{step.status === "complete" ? (
|
||||
<Check size={18} color={theme.colors.success} />
|
||||
) : (
|
||||
<ActivityIndicator color={theme.colors.accent} />
|
||||
)}
|
||||
<Text style={styles.subtitleInline}>{step.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -409,7 +328,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={styles.title}>Something went wrong</Text>
|
||||
<Text style={TITLE_ERROR_STYLE}>Something went wrong</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.errorDescription}>
|
||||
@@ -417,7 +336,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
|
||||
<Text style={styles.errorMessage}>{bootstrapState.error}</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import type {
|
||||
DraggableListDragHandleProps,
|
||||
@@ -58,53 +58,39 @@ import {
|
||||
type WorkspaceTabMenuEntry,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedRows2 = withUnistyles(Rows2);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||
}
|
||||
|
||||
function TabContextMenuItem({
|
||||
entry,
|
||||
iconColor,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "rotate-cw":
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
return <X size={16} color={iconColor} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon]);
|
||||
}, [entry.icon, iconColor]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
@@ -269,6 +255,7 @@ function TabChip({
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
const middleClickRef = useMiddleClickClose(
|
||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||
@@ -405,17 +392,21 @@ function TabChip({
|
||||
>
|
||||
{({ hovered: closeHovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ThemedActivityIndicator
|
||||
<ActivityIndicator
|
||||
size={12}
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
color={
|
||||
closeHovered || pressed
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ThemedX
|
||||
<X
|
||||
size={12}
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
color={
|
||||
closeHovered || pressed
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -441,7 +432,11 @@ function TabChip({
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<TabContextMenuItem key={entry.key} entry={entry} />
|
||||
<TabContextMenuItem
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
@@ -478,6 +473,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabDropPreviewIndex = null,
|
||||
showPaneSplitActions = true,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const newTerminalKeys = useShortcutKeys("workspace-terminal-new");
|
||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||
@@ -503,11 +499,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabGap: 0,
|
||||
maxTabWidth: 200,
|
||||
tabIconWidth: 14,
|
||||
tabHorizontalPadding: 12,
|
||||
tabHorizontalPadding: theme.spacing[3],
|
||||
estimatedCharWidth: 7,
|
||||
closeButtonWidth: 22,
|
||||
}),
|
||||
[tabsActionsWidth],
|
||||
[tabsActionsWidth, theme.spacing],
|
||||
);
|
||||
|
||||
const tabLabelLengths = useMemo(
|
||||
@@ -672,7 +668,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="New agent tab"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<ThemedSquarePen size={14} uniProps={mutedColorMapping} />
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -694,7 +690,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
}
|
||||
style={newTerminalActionButtonStyle}
|
||||
>
|
||||
<ThemedSquareTerminal size={14} uniProps={mutedColorMapping} />
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -716,7 +712,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="Split pane right"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<ThemedColumns2 size={14} uniProps={mutedColorMapping} />
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
@@ -734,7 +730,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
accessibilityLabel="Split pane down"
|
||||
style={newTabActionButtonStyle}
|
||||
>
|
||||
<ThemedRows2 size={14} uniProps={mutedColorMapping} />
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useMemo } from "react";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
Download,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { useGitActions } from "@/hooks/use-git-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceGitActionsProps {
|
||||
serverId: string;
|
||||
@@ -18,31 +18,24 @@ interface WorkspaceGitActionsProps {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const ICONS = {
|
||||
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
|
||||
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
|
||||
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
|
||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
||||
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons: ICONS });
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const icons = useMemo(
|
||||
() => ({
|
||||
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
|
||||
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||
}),
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -8,10 +8,9 @@ import {
|
||||
} from "react-native";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { EditorTargetDescriptorPayload } from "@server/shared/messages";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
|
||||
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,14 +18,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
||||
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
serverId: string;
|
||||
@@ -34,42 +29,31 @@ interface WorkspaceOpenInEditorButtonProps {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
interface OpenTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEditorAppIcon = withUnistyles(EditorAppIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
interface OpenTargetMenuItemProps {
|
||||
target: OpenTarget;
|
||||
interface EditorMenuItemProps {
|
||||
editor: EditorTargetDescriptorPayload;
|
||||
isPreferred: boolean;
|
||||
onOpen: (target: OpenTarget) => void;
|
||||
onOpen: (editorId: EditorTargetId) => void;
|
||||
foregroundMuted: string;
|
||||
}
|
||||
|
||||
function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(target), [onOpen, target]);
|
||||
function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
|
||||
const leading = useMemo(
|
||||
() => <EditorAppIcon editorId={editor.id} size={16} color={foregroundMuted} />,
|
||||
[editor.id, foregroundMuted],
|
||||
);
|
||||
const trailing = useMemo(
|
||||
() => (isPreferred ? <ThemedCheckIcon size={16} uniProps={mutedColorMapping} /> : undefined),
|
||||
[isPreferred],
|
||||
() => (isPreferred ? <Check size={16} color={foregroundMuted} /> : undefined),
|
||||
[isPreferred, foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`workspace-open-in-editor-item-${target.id}`}
|
||||
leading={target.icon}
|
||||
testID={`workspace-open-in-editor-item-${editor.id}`}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{target.label}
|
||||
{editor.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -79,17 +63,18 @@ export function WorkspaceOpenInEditorButton({
|
||||
cwd,
|
||||
hideLabels,
|
||||
}: WorkspaceOpenInEditorButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
||||
|
||||
const shouldLoadTargets =
|
||||
const shouldLoadEditors =
|
||||
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
||||
|
||||
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
||||
queryKey: ["available-editors", serverId],
|
||||
enabled: shouldLoadTargets,
|
||||
enabled: shouldLoadEditors,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -105,65 +90,20 @@ export function WorkspaceOpenInEditorButton({
|
||||
},
|
||||
});
|
||||
|
||||
const availableEditors = useMemo(
|
||||
() => availableEditorsQuery.data ?? [],
|
||||
[availableEditorsQuery.data],
|
||||
const availableEditorsRaw = availableEditorsQuery.data;
|
||||
const availableEditors = useMemo(() => availableEditorsRaw ?? [], [availableEditorsRaw]);
|
||||
const availableEditorIds = useMemo(
|
||||
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
||||
[availableEditors],
|
||||
);
|
||||
|
||||
const { status: checkoutStatus } = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
cwd: shouldLoadTargets ? cwd : "",
|
||||
});
|
||||
|
||||
const editorTargets = useMemo<OpenTarget[]>(
|
||||
() =>
|
||||
availableEditors.map((editor) => ({
|
||||
id: editor.id,
|
||||
label: editor.label,
|
||||
icon: <ThemedEditorAppIcon editorId={editor.id} size={16} uniProps={mutedColorMapping} />,
|
||||
onOpen: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editor.id);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
},
|
||||
})),
|
||||
[availableEditors, client, cwd],
|
||||
);
|
||||
|
||||
const githubTarget = useMemo<OpenTarget | null>(() => {
|
||||
if (!checkoutStatus?.isGit) {
|
||||
return null;
|
||||
}
|
||||
const url = buildGitHubBranchTreeUrl({
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
branch: checkoutStatus.currentBranch,
|
||||
});
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
|
||||
onOpen: () => openExternalUrl(url),
|
||||
};
|
||||
}, [checkoutStatus]);
|
||||
|
||||
const targets = useMemo(
|
||||
() => (githubTarget ? [...editorTargets, githubTarget] : editorTargets),
|
||||
[editorTargets, githubTarget],
|
||||
);
|
||||
|
||||
const targetIds = useMemo(() => targets.map((target) => target.id), [targets]);
|
||||
const effectivePreferredEditorId = useMemo(
|
||||
() => resolvePreferredEditorId(targetIds, preferredEditorId),
|
||||
[targetIds, preferredEditorId],
|
||||
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
||||
[availableEditorIds, preferredEditorId],
|
||||
);
|
||||
const primaryOption = targets.find((target) => target.id === effectivePreferredEditorId) ?? null;
|
||||
const primaryOption =
|
||||
availableEditors.find(
|
||||
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
@@ -173,16 +113,25 @@ export function WorkspaceOpenInEditorButton({
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()),
|
||||
mutationFn: async (editorId: EditorTargetId) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.openInEditor(cwd, editorId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return editorId;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open workspace");
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenTarget = useCallback(
|
||||
(target: OpenTarget) => {
|
||||
void updatePreferredEditor(target.id).catch(() => undefined);
|
||||
openMutation.mutate(target);
|
||||
const handleOpenEditor = useCallback(
|
||||
(editorId: EditorTargetId) => {
|
||||
void updatePreferredEditor(editorId).catch(() => undefined);
|
||||
openMutation.mutate(editorId);
|
||||
},
|
||||
[openMutation, updatePreferredEditor],
|
||||
);
|
||||
@@ -204,13 +153,12 @@ export function WorkspaceOpenInEditorButton({
|
||||
[],
|
||||
);
|
||||
|
||||
const primaryId = primaryOption?.id;
|
||||
const handlePrimaryPress = useCallback(() => {
|
||||
if (primaryOption) {
|
||||
handleOpenTarget(primaryOption);
|
||||
}
|
||||
}, [primaryOption, handleOpenTarget]);
|
||||
if (primaryId) handleOpenEditor(primaryId);
|
||||
}, [primaryId, handleOpenEditor]);
|
||||
|
||||
if (!shouldLoadTargets || !primaryOption || targets.length === 0) {
|
||||
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -226,19 +174,23 @@ export function WorkspaceOpenInEditorButton({
|
||||
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ThemedActivityIndicator
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
uniProps={foregroundColorMapping}
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.splitButtonContent}>
|
||||
{primaryOption.icon}
|
||||
<EditorAppIcon
|
||||
editorId={primaryOption.id}
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{!hideLabels && <Text style={styles.splitButtonText}>Open</Text>}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{targets.length > 1 ? (
|
||||
{availableEditors.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-open-in-editor-caret"
|
||||
@@ -246,7 +198,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose editor"
|
||||
>
|
||||
<ThemedChevronDown size={16} uniProps={mutedColorMapping} />
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
@@ -254,12 +206,13 @@ export function WorkspaceOpenInEditorButton({
|
||||
maxWidth={176}
|
||||
testID="workspace-open-in-editor-menu"
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<OpenTargetMenuItem
|
||||
key={target.id}
|
||||
target={target}
|
||||
isPreferred={target.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenTarget}
|
||||
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
||||
<EditorMenuItem
|
||||
key={editor.id}
|
||||
editor={editor}
|
||||
isPreferred={editor.id === effectivePreferredEditorId}
|
||||
onOpen={handleOpenEditor}
|
||||
foregroundMuted={theme.colors.foregroundMuted}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
@@ -136,32 +135,6 @@ const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedPanelRight = withUnistyles(PanelRight);
|
||||
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
|
||||
|
||||
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||
|
||||
interface WorkspaceScreenProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -325,10 +298,14 @@ function MobileTabTrailingAccessory({
|
||||
menuTestIDBase,
|
||||
presentationLabel,
|
||||
menuEntries,
|
||||
iconColor,
|
||||
iconSizeSm,
|
||||
}: {
|
||||
menuTestIDBase: string;
|
||||
presentationLabel: string;
|
||||
menuEntries: WorkspaceTabMenuEntry[];
|
||||
iconColor: string;
|
||||
iconSizeSm: number;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -339,14 +316,14 @@ function MobileTabTrailingAccessory({
|
||||
hitSlop={8}
|
||||
style={mobileTabMenuTriggerStyle}
|
||||
>
|
||||
<ThemedEllipsis size={14} uniProps={mutedColorMapping} />
|
||||
<Ellipsis size={iconSizeSm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220} testID={menuTestIDBase}>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} />
|
||||
<MobileTabDropdownMenuItem key={entry.key} entry={entry} iconColor={iconColor} />
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -356,27 +333,29 @@ function MobileTabTrailingAccessory({
|
||||
|
||||
function MobileTabDropdownMenuItem({
|
||||
entry,
|
||||
iconColor,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "rotate-cw":
|
||||
return <ThemedRotateCw size={16} uniProps={mutedColorMapping} />;
|
||||
return <RotateCw size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ThemedArrowLeftToLine size={16} uniProps={mutedColorMapping} />;
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
return <X size={16} color={iconColor} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [entry.icon]);
|
||||
}, [entry.icon, iconColor]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
@@ -429,6 +408,7 @@ function MobileWorkspaceTabOption({
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
@@ -445,6 +425,9 @@ function MobileWorkspaceTabOption({
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const iconSizeSm = theme.iconSize.sm;
|
||||
|
||||
const fallbackLabel = getFallbackTabOptionLabel(tab);
|
||||
const trailingAccessory = useMemo(
|
||||
() => (
|
||||
@@ -452,9 +435,11 @@ function MobileWorkspaceTabOption({
|
||||
menuTestIDBase={menuTestIDBase}
|
||||
presentationLabel={fallbackLabel}
|
||||
menuEntries={menuEntries}
|
||||
iconColor={iconColor}
|
||||
iconSizeSm={iconSizeSm}
|
||||
/>
|
||||
),
|
||||
[menuTestIDBase, fallbackLabel, menuEntries],
|
||||
[menuTestIDBase, fallbackLabel, menuEntries, iconColor, iconSizeSm],
|
||||
);
|
||||
|
||||
const renderPresentation = useCallback(
|
||||
@@ -498,6 +483,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
}: MobileWorkspaceTabSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const tabIndexByKey = useMemo(() => {
|
||||
@@ -586,7 +572,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
/>
|
||||
</View>
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
<Combobox
|
||||
@@ -744,9 +730,14 @@ function WorkspaceHeaderMenuTriggerIcon({
|
||||
open: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const Icon = isMobile ? ThemedEllipsisVertical : ThemedEllipsis;
|
||||
const colorMapping = hovered || open ? foregroundColorMapping : mutedColorMapping;
|
||||
return <Icon size={16} uniProps={colorMapping} />;
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = isMobile ? EllipsisVertical : Ellipsis;
|
||||
return (
|
||||
<Icon
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHeaderMenu({
|
||||
@@ -948,6 +939,7 @@ interface RenderWorkspaceContentInput {
|
||||
paneId: string | null;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
}) => WorkspacePaneContentModel;
|
||||
foregroundMutedColor: string;
|
||||
}
|
||||
|
||||
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
||||
@@ -961,12 +953,13 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
buildMobilePaneContentModel,
|
||||
foregroundMutedColor,
|
||||
} = input;
|
||||
|
||||
if (showMissingWorkspaceDescriptor) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
<ActivityIndicator color={foregroundMutedColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -982,7 +975,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
if (!activeTabDescriptor && !hasHydratedAgents) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
<ActivityIndicator color={foregroundMutedColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1122,7 +1115,9 @@ function WorkspaceScreenContent({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const _insets = useSafeAreaInsets();
|
||||
const mainBackgroundColor = theme.colors.surfaceWorkspace;
|
||||
const toast = useToast();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
@@ -2567,6 +2562,7 @@ function WorkspaceScreenContent({
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
buildMobilePaneContentModel,
|
||||
foregroundMutedColor: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const buildDesktopPaneContentModel = useCallback(
|
||||
@@ -2663,12 +2659,27 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const containerStyle = containerWithWorkspaceBackgroundStyle;
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { backgroundColor: mainBackgroundColor }],
|
||||
[mainBackgroundColor],
|
||||
);
|
||||
|
||||
const menuNewAgentIcon = MENU_NEW_AGENT_ICON;
|
||||
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
|
||||
const menuCopyIcon = MENU_COPY_ICON;
|
||||
const menuSettingsIcon = MENU_SETTINGS_ICON;
|
||||
const menuNewAgentIcon = useMemo(
|
||||
() => <SquarePen size={16} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const menuNewTerminalIcon = useMemo(
|
||||
() => <SquareTerminal size={16} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const menuCopyIcon = useMemo(
|
||||
() => <Copy size={16} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const menuSettingsIcon = useMemo(
|
||||
() => <Settings size={16} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
@@ -2710,10 +2721,12 @@ function WorkspaceScreenContent({
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
const iconColor = active
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
@@ -2753,9 +2766,9 @@ function WorkspaceScreenContent({
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const colorMapping =
|
||||
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
return <ThemedPanelRight size={16} uniProps={colorMapping} />;
|
||||
const color =
|
||||
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return <PanelRight size={theme.iconSize.md} color={color} />;
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
) : null}
|
||||
@@ -2773,16 +2786,12 @@ function WorkspaceScreenContent({
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const colorMapping =
|
||||
isExplorerOpen || hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
const color =
|
||||
isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return isGitCheckout ? (
|
||||
<ThemedSourceControlPanelIcon
|
||||
size={20}
|
||||
uniProps={colorMapping}
|
||||
{...sourceControlPanelStrokeWidth15}
|
||||
/>
|
||||
<SourceControlPanelIcon size={theme.iconSize.lg} color={color} strokeWidth={1.5} />
|
||||
) : (
|
||||
<ThemedPanelRight size={20} uniProps={colorMapping} />
|
||||
<PanelRight size={theme.iconSize.lg} color={color} />
|
||||
);
|
||||
}}
|
||||
</HeaderToggleButton>
|
||||
@@ -2803,6 +2812,10 @@ function WorkspaceScreenContent({
|
||||
isExplorerOpen,
|
||||
explorerToggleAccessibilityState,
|
||||
explorerToggleStyle,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.lg,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3026,9 +3039,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
containerWorkspaceBackground: {
|
||||
backgroundColor: theme.colors.surfaceWorkspace,
|
||||
},
|
||||
threePaneRow: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -3323,9 +3333,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const containerWithWorkspaceBackgroundStyle = [
|
||||
styles.container,
|
||||
styles.containerWorkspaceBackground,
|
||||
];
|
||||
|
||||
const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
|
||||
|
||||
@@ -43,17 +43,7 @@ vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
@@ -17,7 +17,6 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type ScriptActionIcon = "start" | "view";
|
||||
|
||||
@@ -40,48 +39,28 @@ interface ScriptActionButtonProps {
|
||||
testID: string;
|
||||
}
|
||||
|
||||
const ThemedPlay = withUnistyles(Play);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const blueColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.blue[500],
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
const playFillTransparent = { fill: "transparent" };
|
||||
|
||||
interface ScriptActionButtonChildrenProps {
|
||||
hovered?: boolean;
|
||||
icon: ScriptActionIcon;
|
||||
label: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}
|
||||
|
||||
function ScriptActionButtonChildren({
|
||||
hovered,
|
||||
icon,
|
||||
label,
|
||||
theme,
|
||||
}: ScriptActionButtonChildrenProps): ReactElement {
|
||||
const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping;
|
||||
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconProps = { size: 10, color };
|
||||
const iconElement =
|
||||
icon === "view" ? (
|
||||
<ThemedSquareTerminal size={10} uniProps={colorMapping} />
|
||||
<SquareTerminal {...iconProps} />
|
||||
) : (
|
||||
<ThemedPlay size={10} uniProps={colorMapping} {...playFillTransparent} />
|
||||
<Play {...iconProps} fill="transparent" />
|
||||
);
|
||||
const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel;
|
||||
const labelStyle = useMemo(() => [styles.actionButtonLabel, { color }], [color]);
|
||||
return (
|
||||
<>
|
||||
{iconElement}
|
||||
@@ -98,6 +77,8 @@ function ScriptActionButton({
|
||||
onPress,
|
||||
testID,
|
||||
}: ScriptActionButtonProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -108,9 +89,9 @@ function ScriptActionButton({
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} />
|
||||
<ScriptActionButtonChildren hovered={hovered} icon={icon} label={label} theme={theme} />
|
||||
),
|
||||
[icon, label],
|
||||
[icon, label, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -142,26 +123,32 @@ interface HostLinkChildrenProps {
|
||||
hovered?: boolean;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
}
|
||||
|
||||
function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement {
|
||||
function HostLinkChildren({
|
||||
hovered,
|
||||
disabled,
|
||||
label,
|
||||
theme,
|
||||
}: HostLinkChildrenProps): ReactElement {
|
||||
const showIcon = !disabled && (hovered || isNative);
|
||||
const isActive = Boolean(hovered) && !disabled;
|
||||
const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping;
|
||||
const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel;
|
||||
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const hostLabelStyle = useMemo(() => [styles.hostLabel, { color }], [color]);
|
||||
return (
|
||||
<>
|
||||
<Text style={hostLabelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.hostIconSlot}>
|
||||
{showIcon ? <ThemedExternalLink size={10} uniProps={colorMapping} /> : null}
|
||||
{showIcon ? <ExternalLink size={10} color={color} /> : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const disabled = !url;
|
||||
|
||||
const handlePress = useCallback(
|
||||
@@ -174,9 +161,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
|
||||
const renderChildren = useCallback(
|
||||
({ hovered }: { hovered?: boolean }) => (
|
||||
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} />
|
||||
<HostLinkChildren hovered={hovered} disabled={disabled} label={label} theme={theme} />
|
||||
),
|
||||
[disabled, label],
|
||||
[disabled, label, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -194,7 +181,9 @@ function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
}
|
||||
|
||||
function ExitCodeBadge({ code }: { code: number }): ReactElement {
|
||||
const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle;
|
||||
const { theme } = useUnistyles();
|
||||
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
|
||||
const exitTextStyle = useMemo(() => [styles.exitBadgeText, { color }], [color]);
|
||||
return (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={exitTextStyle}>exit {code}</Text>
|
||||
@@ -217,24 +206,26 @@ interface ScriptRowProps {
|
||||
: null
|
||||
: null;
|
||||
isStartPending: boolean;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
onStartScript: (scriptName: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
}
|
||||
|
||||
function resolveScriptIconColorMapping(args: {
|
||||
function resolveScriptIconColor(args: {
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
script: WorkspaceDescriptor["scripts"][number];
|
||||
isService: boolean;
|
||||
isRunning: boolean;
|
||||
}): (theme: Theme) => { color: string } {
|
||||
const { script, isService, isRunning } = args;
|
||||
}): string {
|
||||
const { theme, script, isService, isRunning } = args;
|
||||
if (isService) {
|
||||
if (isRunning && script.health === "healthy") return greenColorMapping;
|
||||
if (isRunning && script.health === "unhealthy") return redColorMapping;
|
||||
if (isRunning) return blueColorMapping;
|
||||
return mutedColorMapping;
|
||||
if (isRunning && script.health === "healthy") return theme.colors.palette.green[500];
|
||||
if (isRunning && script.health === "unhealthy") return theme.colors.palette.red[500];
|
||||
if (isRunning) return theme.colors.palette.blue[500];
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
if (isRunning) return blueColorMapping;
|
||||
return mutedColorMapping;
|
||||
if (isRunning) return theme.colors.palette.blue[500];
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
|
||||
function ScriptRow({
|
||||
@@ -242,6 +233,7 @@ function ScriptRow({
|
||||
liveTerminalIdSet,
|
||||
activeConnection,
|
||||
isStartPending,
|
||||
theme,
|
||||
onStartScript,
|
||||
onViewTerminal,
|
||||
}: ScriptRowProps): ReactElement {
|
||||
@@ -276,8 +268,9 @@ function ScriptRow({
|
||||
}
|
||||
}
|
||||
|
||||
const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning });
|
||||
const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal;
|
||||
const iconColor = resolveScriptIconColor({ theme, script, isService, isRunning });
|
||||
|
||||
const ScriptIcon = isService ? Globe : SquareTerminal;
|
||||
const showExitBadge = !isRunning && exitCode !== null;
|
||||
|
||||
const handleView = useCallback(() => {
|
||||
@@ -289,8 +282,13 @@ function ScriptRow({
|
||||
}, [onStartScript, script.scriptName]);
|
||||
|
||||
const scriptNameStyle = useMemo(
|
||||
() => (isRunning ? scriptNameActiveStyle : styles.scriptName),
|
||||
[isRunning],
|
||||
() => [
|
||||
styles.scriptName,
|
||||
{
|
||||
color: isRunning ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||
},
|
||||
],
|
||||
[isRunning, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
let primaryAction: ReactElement | null = null;
|
||||
@@ -324,7 +322,7 @@ function ScriptRow({
|
||||
style={styles.scriptItem}
|
||||
>
|
||||
<View style={styles.scriptHeader}>
|
||||
<ScriptIcon size={14} uniProps={iconColorMapping} style={styles.scriptIcon} />
|
||||
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
|
||||
<Text style={scriptNameStyle} numberOfLines={1}>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
@@ -357,6 +355,7 @@ export function WorkspaceScriptsButton({
|
||||
onViewTerminal,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
@@ -403,7 +402,6 @@ export function WorkspaceScriptsButton({
|
||||
}
|
||||
|
||||
const hasAnyRunning = scripts.some((s) => s.lifecycle === "running");
|
||||
const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -416,9 +414,15 @@ export function WorkspaceScriptsButton({
|
||||
accessibilityLabel="Workspace scripts"
|
||||
>
|
||||
<View style={styles.splitButtonContent}>
|
||||
<ThemedPlay size={14} uniProps={triggerPlayMapping} {...playFillTransparent} />
|
||||
<Play
|
||||
size={14}
|
||||
color={
|
||||
hasAnyRunning ? theme.colors.palette.blue[500] : theme.colors.foregroundMuted
|
||||
}
|
||||
fill="transparent"
|
||||
/>
|
||||
{!hideLabels && <Text style={styles.splitButtonText}>Scripts</Text>}
|
||||
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -436,6 +440,7 @@ export function WorkspaceScriptsButton({
|
||||
liveTerminalIdSet={liveTerminalIdSet}
|
||||
activeConnection={activeConnection}
|
||||
isStartPending={startScriptMutation.isPending}
|
||||
theme={theme}
|
||||
onStartScript={handleStartScript}
|
||||
onViewTerminal={onViewTerminal}
|
||||
/>
|
||||
@@ -506,10 +511,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scriptNameActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
@@ -530,10 +531,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hostLabelActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostIconSlot: {
|
||||
width: 10,
|
||||
@@ -552,10 +549,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
exitBadgeTextError: {
|
||||
color: theme.colors.palette.red[300],
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
@@ -565,14 +558,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
actionButtonLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
actionButtonLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered];
|
||||
const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive];
|
||||
const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive];
|
||||
const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError];
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export interface WorkspaceTabPresentation {
|
||||
key: string;
|
||||
@@ -104,22 +103,22 @@ interface WorkspaceTabIconProps {
|
||||
statusDotBorderColor?: string;
|
||||
}
|
||||
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export function WorkspaceTabIcon({
|
||||
presentation,
|
||||
active = false,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
const bucket = presentation.statusBucket;
|
||||
let statusDotColor: string | null = null;
|
||||
if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color;
|
||||
else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const statusDotColor =
|
||||
presentation.statusBucket === null
|
||||
? null
|
||||
: getStatusDotColor({
|
||||
theme,
|
||||
bucket: presentation.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
@@ -140,20 +139,27 @@ export function WorkspaceTabIcon({
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor ?? undefined,
|
||||
borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset],
|
||||
[statusDotColor, statusDotBorderColor, theme.colors.surface0, statusDotSize, statusDotOffset],
|
||||
);
|
||||
|
||||
if (shouldShowLoader) {
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
<SyncedLoader size={size - 1} color={styles.syncedLoader.color} />
|
||||
<SyncedLoader
|
||||
size={size - 1}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +187,7 @@ export function WorkspaceTabOptionRow({
|
||||
onPress,
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.optionMainPressable,
|
||||
@@ -206,7 +213,7 @@ export function WorkspaceTabOptionRow({
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
{trailingAccessory ? (
|
||||
@@ -231,33 +238,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusDotBorderDefault: {
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusDotNeedsInput: {
|
||||
color: theme.colors.palette.amber[500],
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
statusDotRunning: {
|
||||
color: theme.colors.palette.blue[500],
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: theme.colors.palette.green[500],
|
||||
},
|
||||
iconActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
iconInactive: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
syncedLoader: {
|
||||
color:
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500],
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn<(_: string) => Promise<string | null>>(),
|
||||
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
resolve = nextResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function installWindowStub(pathname: string) {
|
||||
const windowStub = {
|
||||
location: {
|
||||
@@ -94,10 +75,6 @@ function createNavigationPathWithParamsRef(path: string, serverId: string, works
|
||||
describe("navigation active workspace store", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockReset();
|
||||
asyncStorageMock.setItem.mockReset();
|
||||
asyncStorageMock.getItem.mockResolvedValue(null);
|
||||
asyncStorageMock.setItem.mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -196,58 +173,4 @@ describe("navigation active workspace store", () => {
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates the last workspace route selection from storage on startup", async () => {
|
||||
const storedSelection = createDeferred<string | null>();
|
||||
asyncStorageMock.getItem.mockReturnValue(storedSelection.promise);
|
||||
installWindowStub("/open-project");
|
||||
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(false);
|
||||
const hydration = store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
storedSelection.resolve(JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }));
|
||||
await hydration;
|
||||
|
||||
expect(asyncStorageMock.getItem).toHaveBeenCalledWith(
|
||||
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
||||
);
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it("hydrates empty and corrupt workspace route storage as null", async () => {
|
||||
installWindowStub("/open-project");
|
||||
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(null);
|
||||
let store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
|
||||
vi.resetModules();
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce("{not json");
|
||||
store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toBeNull();
|
||||
expect(store.getIsLastNavigationWorkspaceRouteSelectionLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it("persists valid last workspace route selections", async () => {
|
||||
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||
const store = await import("@/stores/navigation-active-workspace-store");
|
||||
await store.hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationRef("server-1", "workspace-a"));
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
|
||||
import {
|
||||
@@ -39,13 +38,8 @@ interface NavigationObserverRef {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
let snapshot: ActiveWorkspaceSelection | null = null;
|
||||
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
|
||||
let isLastWorkspaceRouteSelectionLoaded = false;
|
||||
let lastWorkspaceRouteSelectionRevision = 0;
|
||||
let lastWorkspaceRouteSelectionHydrationPromise: Promise<void> | null = null;
|
||||
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -60,87 +54,13 @@ function getSnapshot(): ActiveWorkspaceSelection | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function emitIfChanged(next: ActiveWorkspaceSelection | null) {
|
||||
if (snapshot?.serverId === next?.serverId && snapshot?.workspaceId === next?.workspaceId) {
|
||||
return;
|
||||
}
|
||||
snapshot = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
function parseStoredLastWorkspaceRouteSelection(
|
||||
stored: string | null,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeActiveWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActiveWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = input as Record<string, unknown>;
|
||||
const serverId = typeof selection.serverId === "string" ? selection.serverId.trim() : "";
|
||||
const workspaceId = typeof selection.workspaceId === "string" ? selection.workspaceId.trim() : "";
|
||||
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function persistLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
void AsyncStorage.setItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY, JSON.stringify(next)).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
function setLastWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
const normalized = normalizeActiveWorkspaceSelection(next);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastWorkspaceRouteSelection?.serverId === normalized.serverId &&
|
||||
lastWorkspaceRouteSelection.workspaceId === normalized.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWorkspaceRouteSelectionRevision += 1;
|
||||
lastWorkspaceRouteSelection = normalized;
|
||||
persistLastWorkspaceRouteSelection(normalized);
|
||||
}
|
||||
|
||||
async function readLastWorkspaceRouteSelectionFromStorage(hydrationRevision: number) {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(LAST_WORKSPACE_ROUTE_SELECTION_STORAGE_KEY);
|
||||
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
|
||||
lastWorkspaceRouteSelection = parseStoredLastWorkspaceRouteSelection(stored);
|
||||
}
|
||||
} catch {
|
||||
if (lastWorkspaceRouteSelectionRevision === hydrationRevision) {
|
||||
lastWorkspaceRouteSelection = null;
|
||||
}
|
||||
} finally {
|
||||
isLastWorkspaceRouteSelectionLoaded = true;
|
||||
notifyListeners();
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +170,7 @@ export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverR
|
||||
const route = navigationRef.current?.getCurrentRoute();
|
||||
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||
if (routeState.kind === "workspace") {
|
||||
setLastWorkspaceRouteSelection(routeState.selection);
|
||||
lastWorkspaceRouteSelection = routeState.selection;
|
||||
if (nextWorkspaceRouteSelectionOverride) {
|
||||
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
||||
nextWorkspaceRouteSelectionOverride = null;
|
||||
@@ -277,22 +197,6 @@ export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelec
|
||||
return lastWorkspaceRouteSelection;
|
||||
}
|
||||
|
||||
export function getIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||
return isLastWorkspaceRouteSelectionLoaded;
|
||||
}
|
||||
|
||||
export function hydrateLastNavigationWorkspaceRouteSelection(): Promise<void> {
|
||||
if (lastWorkspaceRouteSelectionHydrationPromise) {
|
||||
return lastWorkspaceRouteSelectionHydrationPromise;
|
||||
}
|
||||
|
||||
const hydrationRevision = lastWorkspaceRouteSelectionRevision;
|
||||
lastWorkspaceRouteSelectionHydrationPromise =
|
||||
readLastWorkspaceRouteSelectionFromStorage(hydrationRevision);
|
||||
|
||||
return lastWorkspaceRouteSelectionHydrationPromise;
|
||||
}
|
||||
|
||||
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
nextWorkspaceRouteSelectionOverride = next;
|
||||
}
|
||||
@@ -319,14 +223,6 @@ export function useNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
export function useIsLastNavigationWorkspaceRouteSelectionLoaded(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
getIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsNavigationWorkspaceSelected(input: {
|
||||
serverId: string | null;
|
||||
workspaceId: string | null;
|
||||
@@ -347,8 +243,6 @@ export function useIsNavigationWorkspaceSelected(input: {
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastNavigationWorkspaceRouteSelection();
|
||||
|
||||
export function useIsNavigationProjectActive(input: {
|
||||
serverId: string | null;
|
||||
workspaceIds: readonly string[];
|
||||
|
||||
@@ -115,50 +115,6 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
|
||||
expect(workspace.scripts).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves project placement from workspace descriptor payloads", () => {
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "1",
|
||||
projectId: "remote:github.com/acme/app",
|
||||
projectDisplayName: "acme/app",
|
||||
projectRootPath: "/repo/app",
|
||||
workspaceDirectory: "/repo/app",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
project: {
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspace.project).toEqual({
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
checkout: {
|
||||
cwd: "/repo/app",
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
worktreeRoot: "/repo/app",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
|
||||
@@ -119,9 +119,6 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
scripts: WorkspaceDescriptorPayload["scripts"];
|
||||
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
||||
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
||||
project?: ProjectPlacementPayload;
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -139,9 +136,6 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
||||
gitRuntime: payload.gitRuntime,
|
||||
githubRuntime: payload.githubRuntime,
|
||||
project: payload.project,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ const lightSemanticColors = {
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
// Semantic
|
||||
destructive: "#b04138", // dark warm red on white — calm but unambiguously red
|
||||
destructive: "#dc2626",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: "#20744A",
|
||||
successForeground: "#ffffff",
|
||||
@@ -233,7 +233,6 @@ interface DarkThemeConfig {
|
||||
borderAccent: string;
|
||||
accent: string;
|
||||
accentBright: string;
|
||||
destructive: string;
|
||||
}
|
||||
|
||||
const darkTerminalAnsi = {
|
||||
@@ -277,7 +276,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
accentBright: tint.accentBright,
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
destructive: tint.destructive,
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: tint.accent,
|
||||
successForeground: "#ffffff",
|
||||
@@ -333,7 +332,6 @@ const paseoDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#2F3534",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
destructive: "#c64f43", // warm red, hue ~7 — reads as red (not pink) against the green tint
|
||||
});
|
||||
|
||||
// Zinc — neutral gray, no tint
|
||||
@@ -352,7 +350,6 @@ const zincDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#303036",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming
|
||||
});
|
||||
|
||||
// Midnight — subtle blue tint
|
||||
@@ -371,7 +368,6 @@ const midnightDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#2e3040",
|
||||
accent: "#3b6fcf",
|
||||
accentBright: "#7eaaeb",
|
||||
destructive: "#c44a52", // red with a hint of cool lean against the blue tint
|
||||
});
|
||||
|
||||
// Claude — warm neutral with subtle orange undertone
|
||||
@@ -390,7 +386,6 @@ const claudeDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#36332f",
|
||||
accent: "#d97757",
|
||||
accentBright: "#e89a7f",
|
||||
destructive: "#cf513e", // warm orange-red, hue ~10 — sits with the Claude orange accent
|
||||
});
|
||||
|
||||
// Ghostty — blue-tinted dark based on Ghostty default background
|
||||
@@ -409,86 +404,76 @@ const ghosttyDarkColors = buildDarkSemanticColors({
|
||||
borderAccent: "#3f4454",
|
||||
accent: "#89b4fa",
|
||||
accentBright: "#b4d0fc",
|
||||
destructive: "#c44a55", // red with slight cool lean against the slate-blue surfaces
|
||||
});
|
||||
|
||||
export const SPACING = {
|
||||
0: 0,
|
||||
1: 4,
|
||||
1.5: 6,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
20: 80,
|
||||
24: 96,
|
||||
32: 128,
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZE = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
"2xl": 22,
|
||||
"3xl": 26,
|
||||
"4xl": 34,
|
||||
} as const;
|
||||
|
||||
export const LINE_HEIGHT = {
|
||||
diff: 22,
|
||||
} as const;
|
||||
|
||||
export const ICON_SIZE = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
} as const;
|
||||
|
||||
export const FONT_WEIGHT = {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
semibold: "600" as const,
|
||||
bold: "bold" as const,
|
||||
} as const;
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
none: 0,
|
||||
sm: 2,
|
||||
base: 4,
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
} as const;
|
||||
|
||||
export const BORDER_WIDTH = {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
} as const;
|
||||
|
||||
export const OPACITY = {
|
||||
0: 0,
|
||||
50: 0.5,
|
||||
100: 1,
|
||||
} as const;
|
||||
|
||||
const commonTheme = {
|
||||
spacing: SPACING,
|
||||
fontSize: FONT_SIZE,
|
||||
lineHeight: LINE_HEIGHT,
|
||||
iconSize: ICON_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
borderRadius: BORDER_RADIUS,
|
||||
borderWidth: BORDER_WIDTH,
|
||||
opacity: OPACITY,
|
||||
spacing: {
|
||||
0: 0,
|
||||
1: 4,
|
||||
1.5: 6,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
6: 24,
|
||||
8: 32,
|
||||
12: 48,
|
||||
16: 64,
|
||||
20: 80,
|
||||
24: 96,
|
||||
32: 128,
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
"2xl": 22,
|
||||
"3xl": 26,
|
||||
"4xl": 34,
|
||||
},
|
||||
|
||||
lineHeight: {
|
||||
diff: 22,
|
||||
},
|
||||
|
||||
iconSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
},
|
||||
|
||||
fontWeight: {
|
||||
normal: "normal" as const,
|
||||
medium: "500" as const,
|
||||
semibold: "600" as const,
|
||||
bold: "bold" as const,
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
none: 0,
|
||||
sm: 2,
|
||||
base: 4,
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
"2xl": 16,
|
||||
full: 9999,
|
||||
},
|
||||
|
||||
borderWidth: {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
|
||||
opacity: {
|
||||
0: 0,
|
||||
50: 0.5,
|
||||
100: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const darkShadow = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user