mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 | ||
|
|
4141c76258 | ||
|
|
7f44323686 | ||
|
|
152b07b599 | ||
|
|
84f36d2e20 | ||
|
|
25d4c5023a | ||
|
|
3f5acfff31 | ||
|
|
b9940e285c | ||
|
|
9993c6c6c3 | ||
|
|
3b7971a463 | ||
|
|
d75d2d857d | ||
|
|
cab42985a5 | ||
|
|
ef892bd27d | ||
|
|
3014576c4c | ||
|
|
b8c77bf0e3 | ||
|
|
17073fe8ff | ||
|
|
6220b47073 | ||
|
|
bf7f8f686b | ||
|
|
93cd4734ce | ||
|
|
e4acd6cb7a | ||
|
|
ca11fc667b | ||
|
|
36e54a097e | ||
|
|
ecd3137d34 | ||
|
|
f881f9ae32 | ||
|
|
3f6b84899a | ||
|
|
5e64a1340c | ||
|
|
2d0ed004e2 | ||
|
|
183cda2b66 | ||
|
|
ed2a97fda8 | ||
|
|
2fed0f09bb | ||
|
|
478aa4b70e | ||
|
|
fd74abcdca | ||
|
|
444e265275 | ||
|
|
2ee9329663 | ||
|
|
b30aafc2bd | ||
|
|
90cb20ce79 | ||
|
|
bd6feac235 | ||
|
|
9f3256a254 | ||
|
|
4165dbe8e9 | ||
|
|
15e15b525f | ||
|
|
371c29fecd | ||
|
|
7d823fd3ec | ||
|
|
ab4525db5b | ||
|
|
73d27bd5fe | ||
|
|
6db5200890 | ||
|
|
9dd5c6e5f3 | ||
|
|
407c4ca554 | ||
|
|
47f26e99db | ||
|
|
263886fc1c | ||
|
|
bb5ac5b1d0 | ||
|
|
9c1c2cea76 | ||
|
|
c0037e56af | ||
|
|
4f16056578 | ||
|
|
a4bcc7b169 | ||
|
|
f17fb77013 | ||
|
|
0079f2a875 | ||
|
|
0abcd21cd3 | ||
|
|
5fb6e0e74d | ||
|
|
d88af28e93 | ||
|
|
73f35537f9 | ||
|
|
e5deb90986 | ||
|
|
d5ffc51f07 | ||
|
|
861917836a |
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Bug report
|
||||
description: Something is broken or doesn't behave the way it should.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
- try to reproduce on the latest version
|
||||
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What's broken
|
||||
description: What happened, and what did you expect to happen instead?
|
||||
placeholder: |
|
||||
I tried to X, expected Y, got Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
|
||||
placeholder: |
|
||||
1. Open the desktop app
|
||||
2. Pair a daemon
|
||||
3. Click X
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where did this happen
|
||||
description: The surface you saw the bug on. Pick the closest match.
|
||||
options:
|
||||
- iOS app
|
||||
- Android app
|
||||
- Web (browser)
|
||||
- Desktop (Electron)
|
||||
- CLI
|
||||
- Daemon
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: paseo-version
|
||||
attributes:
|
||||
label: Paseo version
|
||||
description: Settings → About in the app, or `paseo --version` from the CLI.
|
||||
placeholder: "0.1.71"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
|
||||
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Agent provider
|
||||
description: If the bug involves a specific agent provider, pick which one.
|
||||
options:
|
||||
- Not relevant
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Custom provider
|
||||
|
||||
- type: textarea
|
||||
id: provider-details
|
||||
attributes:
|
||||
label: Provider configuration
|
||||
description: |
|
||||
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
|
||||
placeholder: |
|
||||
Claude Code v1.2.3 with Anthropic API
|
||||
Codex CLI v0.5.0 with OpenAI API
|
||||
OpenCode v0.3.1
|
||||
(or paste the relevant section of ~/.paseo/config.json for custom providers)
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Paste relevant log output. Strongly preferred for crashes and daemon issues.
|
||||
|
||||
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
|
||||
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
|
||||
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
|
||||
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
|
||||
|
||||
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or video
|
||||
description: |
|
||||
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
|
||||
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Feature request
|
||||
description: Propose a new feature or a change to existing behavior.
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
|
||||
|
||||
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
|
||||
|
||||
- type: checkboxes
|
||||
id: prior-search
|
||||
attributes:
|
||||
label: Prior search
|
||||
options:
|
||||
- label: I searched existing issues and discussions, and this isn't already proposed.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem
|
||||
description: What are you actually trying to do, and why is the current behavior in the way?
|
||||
placeholder: |
|
||||
When I'm doing X, I want to Y, but Paseo currently Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would solve it
|
||||
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you considered
|
||||
description: Optional. What else did you try, and why doesn't it work?
|
||||
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
||||
|
||||
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
### Linked issue
|
||||
|
||||
Closes #
|
||||
|
||||
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
|
||||
|
||||
### Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature (with prior issue + design alignment)
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Docs
|
||||
|
||||
### What does this PR do
|
||||
|
||||
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
|
||||
|
||||
### How did you verify it
|
||||
|
||||
<!--
|
||||
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
||||
|
||||
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
||||
- For behavior changes: the actual steps you ran, and what you observed.
|
||||
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
||||
|
||||
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
|
||||
-->
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] One focused change. Unrelated cleanups split out.
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run format` ran (Biome)
|
||||
- [ ] UI changes include screenshots or video for every affected platform
|
||||
- [ ] Tests added or updated where it made sense
|
||||
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@@ -5,8 +5,13 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,7 +24,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -35,7 +40,13 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint lockfile
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -51,7 +62,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -86,7 +97,7 @@ jobs:
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
@@ -118,7 +129,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -143,7 +154,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -165,7 +176,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -208,7 +219,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
@@ -232,7 +243,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
2
.github/workflows/deploy-relay.yml
vendored
2
.github/workflows/deploy-relay.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
2
.github/workflows/deploy-website.yml
vendored
2
.github/workflows/deploy-website.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
72
.github/workflows/nix-build.yml
vendored
72
.github/workflows/nix-build.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates (main push only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
99
.github/workflows/nix.yml
vendored
Normal file
99
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
55
.github/workflows/server-ci.yml
vendored
55
.github/workflows/server-ci.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Install Claude Code CLI for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -1,39 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.75 - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
|
||||
- NixOS: `services.paseo.settings` renders declarative daemon config to disk, and typed `services.paseo.relay` options auto-wire the relay endpoint. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
||||
- NixOS: Paseo desktop is now packaged in the flake — `nix run github:getpaseo/paseo#desktop` launches the Electron app. ([#923](https://github.com/getpaseo/paseo/pull/923) by [@ixxie](https://github.com/ixxie))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
|
||||
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
|
||||
- Copilot's **Allow All** mode (previously misnamed Autopilot) now actually suppresses tool, path, and URL permission prompts. ([#935](https://github.com/getpaseo/paseo/pull/935))
|
||||
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
|
||||
- iPhone HEIC photos now attach correctly from the image picker. ([#934](https://github.com/getpaseo/paseo/pull/934))
|
||||
- Scheduled agents now archive automatically after each run instead of piling up in the active list. ([#945](https://github.com/getpaseo/paseo/pull/945))
|
||||
- Windows: Codex command summaries show the underlying command instead of `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
|
||||
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.74 - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
|
||||
|
||||
## 0.1.73 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
|
||||
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
|
||||
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
|
||||
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
|
||||
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
|
||||
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
|
||||
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
|
||||
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.72 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
|
||||
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
|
||||
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
|
||||
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
|
||||
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
|
||||
|
||||
## 0.1.71 - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Import existing Claude, Codex, and OpenCode sessions** straight into Paseo — pick up a conversation you started in the terminal and keep going from the app, with full timeline rehydration. ([#766](https://github.com/getpaseo/paseo/pull/766))
|
||||
- **Subagents now appear in a collapsible section above the composer** so you can see and jump into the agents your main agent has spawned, without leaving the workspace. ([#532](https://github.com/getpaseo/paseo/pull/532))
|
||||
- **Merge PR action in the checkout pane** — merge a pull request directly from Paseo, with a real-GitHub end-to-end test backing it. ([#814](https://github.com/getpaseo/paseo/pull/814))
|
||||
- **Project-level prompts for metadata generation** — customize how titles and summaries are generated per project. ([#836](https://github.com/getpaseo/paseo/pull/836))
|
||||
- **Grouped project settings** with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
|
||||
- **Create an empty workspace without writing a prompt** — useful for opening a project just to browse or import a session. ([#834](https://github.com/getpaseo/paseo/pull/834))
|
||||
- **Import-agent pill above the draft composer** so you can quickly resume a recent terminal session as a Paseo agent. ([#833](https://github.com/getpaseo/paseo/pull/833))
|
||||
- **Rich context menu on desktop** — copy link, copy image, and spellcheck suggestions in the Electron desktop app.
|
||||
- **Codex `/archive` integration** — archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
|
||||
- **Import existing Claude, Codex, and OpenCode sessions** into Paseo — pick up a conversation you started in the terminal and keep going from the app, with the full timeline. ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833))
|
||||
- **Subagents now appear in a collapsible section above the composer** so you can jump into agents your main agent spawned. ([#532](https://github.com/getpaseo/paseo/pull/532))
|
||||
- Merge a pull request directly from the checkout pane. ([#814](https://github.com/getpaseo/paseo/pull/814))
|
||||
- Customize the per-project prompts Paseo uses to auto-generate agent titles, branch names, commit messages, and pull request descriptions. ([#836](https://github.com/getpaseo/paseo/pull/836))
|
||||
- Open an empty workspace without typing a prompt first. ([#834](https://github.com/getpaseo/paseo/pull/834))
|
||||
- Project settings are now grouped with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
|
||||
- Rich context menu on desktop — copy link, copy image, and spellcheck suggestions.
|
||||
- Archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace navigation auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
|
||||
- MCP `create_agent` inherits the caller's unattended mode when launching an agent on a different provider, instead of falling back to attended.
|
||||
- Subagent task notifications no longer flicker on archive, and the subagents track displays correctly across reloads.
|
||||
- Opening a workspace auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
|
||||
- An unattended agent that spawns a sub-agent on a different provider via MCP now starts the sub-agent in unattended mode too.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Reload now properly rehydrates the timeline from disk** under a fresh epoch, so a reloaded imported agent picks up new entries instead of getting stuck on the previous view.
|
||||
- **Chat fanout and system-injected agent prompts unified** — fixes inconsistent delivery and formatting of system messages routed to multiple agents. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- iOS project picker now submits the typed path correctly. ([#829](https://github.com/getpaseo/paseo/issues/829), [#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- The composer's working directory now sources from the agent's actual state, not the draft hook, so it stays in sync after reloads.
|
||||
- Workspace open no longer auto-focuses subagents.
|
||||
- Ephemeral Claude session transcripts are deleted on close so internal generator runs don't pollute the import sheet.
|
||||
- Markdown link clicks no longer trigger the underlying anchor's default behavior.
|
||||
- `QueryProvider` is hoisted above `PortalProvider` so portaled sheets can read from the React Query client.
|
||||
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
|
||||
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Local state file permissions are now restricted** so daemon keypairs, persisted config, and token stores are readable only by the owner. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
|
||||
- Local state files (daemon keypair, stored credentials, persisted config) are now readable only by the owning user. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.70 - 2026-05-08
|
||||
|
||||
|
||||
153
CONTRIBUTING.md
153
CONTRIBUTING.md
@@ -1,31 +1,24 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
|
||||
## How this project works
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
|
||||
|
||||
This means:
|
||||
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
|
||||
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
|
||||
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
|
||||
|
||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||
## Reporting bugs
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
|
||||
|
||||
## How to contribute
|
||||
|
||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||
2. **Keep it small.** One bug, one flow, one focused change.
|
||||
3. **Open a PR** once there is alignment on scope.
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
|
||||
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
|
||||
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
|
||||
- **One bug per issue.** Three findings, three issues.
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
@@ -35,134 +28,36 @@ Please read these first:
|
||||
|
||||
## What is most helpful
|
||||
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- bug fixes (especially Windows and Linux)
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing product direction
|
||||
- packaging and platform fixes
|
||||
- focused UX improvements that fit the product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Scope expectations
|
||||
|
||||
Please keep PRs narrow.
|
||||
|
||||
Good:
|
||||
|
||||
- fix one bug
|
||||
- improve one flow
|
||||
- add one focused panel or command
|
||||
- tighten one piece of UI
|
||||
|
||||
Bad:
|
||||
|
||||
- combine multiple product ideas in one PR
|
||||
- bundle unrelated refactors with a feature
|
||||
- sneak in roadmap decisions
|
||||
|
||||
If a contribution contains multiple ideas, split it up.
|
||||
|
||||
## Product fit matters
|
||||
|
||||
Paseo is an opinionated product.
|
||||
|
||||
When reviewing contributions, the bar is not just:
|
||||
|
||||
- is this useful?
|
||||
- is this well implemented?
|
||||
|
||||
It is also:
|
||||
|
||||
- does this fit Paseo?
|
||||
- does this add product surface that will be hard to maintain?
|
||||
- does the value justify the maintenance surface it adds?
|
||||
- does this solve a common need or over-serve an edge case?
|
||||
- does this preserve the product's current direction?
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js matching `.tool-versions`
|
||||
- npm workspaces
|
||||
|
||||
### Start local development
|
||||
|
||||
```bash
|
||||
# runs both daemon and expo app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run dev # daemon + expo
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
npm run cli -- ls -a -g
|
||||
```
|
||||
|
||||
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## Multi-platform testing
|
||||
## Pull requests
|
||||
|
||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||
- One focused change per PR. Split unrelated cleanups out.
|
||||
- Reference the issue you're fixing, unless it's a small objective bug.
|
||||
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
|
||||
- `npm run typecheck` and `npm run lint` must pass.
|
||||
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
|
||||
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspaces --if-present
|
||||
```
|
||||
|
||||
Important rules:
|
||||
|
||||
- always run `npm run typecheck` after changes
|
||||
- tests should be deterministic
|
||||
- prefer real dependencies over mocks when possible
|
||||
- do not make breaking WebSocket / protocol changes
|
||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||
|
||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Coding standards
|
||||
|
||||
Paseo has explicit standards. Follow them.
|
||||
|
||||
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## PR checklist
|
||||
|
||||
Before opening a PR, make sure:
|
||||
|
||||
- there was prior discussion and alignment on scope (issue or conversation)
|
||||
- the change is focused, one idea per PR
|
||||
- the PR description explains what changed and why
|
||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||
- UI changes have been tested on mobile and web at minimum
|
||||
- typecheck passes
|
||||
- tests pass, or you clearly explain what could not be run
|
||||
- relevant docs were updated if needed
|
||||
|
||||
## Communication
|
||||
|
||||
If you are unsure whether something fits, ask first.
|
||||
|
||||
That is especially true for:
|
||||
|
||||
- new core UX
|
||||
- naming / terminology changes
|
||||
- new extension points
|
||||
- new orchestration models
|
||||
- anything that would be hard to remove later
|
||||
|
||||
Early alignment saves everyone time.
|
||||
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, a fork is completely fine.
|
||||
|
||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
|
||||
|
||||
@@ -103,7 +103,7 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
@@ -59,6 +59,30 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
|
||||
@@ -39,7 +39,14 @@ In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
@@ -26,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
157
nix/desktop-package.nix
Normal file
157
nix/desktop-package.nix
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
|
||||
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
|
||||
# different value.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,48 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,9 +152,50 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,6 +213,10 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
@@ -149,6 +235,9 @@ in
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +261,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-LczD9EmK6LuaJuZQu1v/q8zBE92LVynRR85dp6IdfCo=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# 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-ocBSTgfzPlucgcCaPRi3iYhxpZD6hTzD3b2lphSTtb4=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# 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).
|
||||
|
||||
929
package-lock.json
generated
929
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -414,15 +414,19 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
|
||||
@@ -21,12 +21,12 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
await page.getByRole("button", { name: "Close menu" }).click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
@@ -115,7 +116,7 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.59.1",
|
||||
"wrangler": "^4.75.0",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { 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";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
@@ -72,7 +71,6 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
@@ -85,13 +83,12 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -118,25 +115,8 @@ function PushNotificationRouter() {
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId);
|
||||
const workspaceId =
|
||||
target.workspaceId ??
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: session?.workspaces.values(),
|
||||
workspaceDirectory: agent?.cwd,
|
||||
});
|
||||
|
||||
if (workspaceId) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: true,
|
||||
});
|
||||
navigateToWorkspace(serverId, workspaceId, { currentPathname: pathname });
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
router.navigate(buildNotificationRoute(data));
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
@@ -333,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||
function AdaptiveTextInput(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
if (isMobile) {
|
||||
if (isMobile && isNative) {
|
||||
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
@@ -19,9 +18,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
@@ -312,23 +309,13 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
});
|
||||
|
||||
onAgentSelect?.();
|
||||
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
|
||||
return;
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
agentId,
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
@@ -443,7 +443,7 @@ function ProviderSearchInput({
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
|
||||
|
||||
@@ -791,10 +791,13 @@ function DesktopSidebar({
|
||||
|
||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
|
||||
[resizeAnimatedStyle, insetsTop],
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
|
||||
@@ -66,13 +66,8 @@ import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
@@ -2858,7 +2853,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||
@@ -2875,63 +2869,36 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const displayDetail = useMemo<ToolCallDetail>(
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
effectiveDetail ?? {
|
||||
type: "unknown",
|
||||
input: null,
|
||||
output: null,
|
||||
},
|
||||
[effectiveDetail],
|
||||
);
|
||||
|
||||
const displayModel = useMemo(
|
||||
() =>
|
||||
buildToolCallDisplayModel({
|
||||
name: toolName,
|
||||
status: status === "executing" ? "running" : status,
|
||||
buildToolCallPresentation({
|
||||
toolName,
|
||||
status,
|
||||
error: error ?? null,
|
||||
detail: displayDetail,
|
||||
detail: effectiveDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, displayDetail, metadata, cwd],
|
||||
);
|
||||
const displayName = displayModel.displayName;
|
||||
const summary = displayModel.summary;
|
||||
const errorText = displayModel.errorText;
|
||||
const IconComponent = resolveToolCallIcon(toolName, effectiveDetail);
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: effectiveDetail,
|
||||
status,
|
||||
error,
|
||||
});
|
||||
const secondaryLabel = summary;
|
||||
|
||||
// Check if there's any content to display
|
||||
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
|
||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||
|
||||
const extractedFilePath = useMemo(
|
||||
() => extractToolCallFilePath(effectiveDetail),
|
||||
[effectiveDetail],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
if (!extractedFilePath || !onOpenFilePath) {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
if (!openFilePath || !onOpenFilePath) {
|
||||
return undefined;
|
||||
}
|
||||
return () => onOpenFilePath(extractedFilePath);
|
||||
}, [extractedFilePath, onOpenFilePath]);
|
||||
return () => onOpenFilePath(openFilePath);
|
||||
}, [presentation.openFilePath, onOpenFilePath]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
openToolCall({
|
||||
toolName,
|
||||
displayName,
|
||||
summary: secondaryLabel,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
errorText,
|
||||
showLoadingSkeleton: isLoadingDetails,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
@@ -2939,12 +2906,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
isMobile,
|
||||
openToolCall,
|
||||
toolName,
|
||||
displayName,
|
||||
secondaryLabel,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
effectiveDetail,
|
||||
errorText,
|
||||
isLoadingDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2980,14 +2947,14 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
detail={effectiveDetail}
|
||||
errorText={errorText}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={400}
|
||||
showLoadingSkeleton={isLoadingDetails}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
/>
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
|
||||
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
@@ -3001,13 +2968,13 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={displayName}
|
||||
secondaryLabel={secondaryLabel}
|
||||
icon={IconComponent}
|
||||
label={presentation.displayName}
|
||||
secondaryLabel={presentation.summary}
|
||||
icon={presentation.icon}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
onToggle={canOpenDetails ? handleToggle : undefined}
|
||||
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
|
||||
onOpenFile={handleOpenFile}
|
||||
renderDetails={canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
|
||||
isLoading={status === "running" || status === "executing"}
|
||||
isError={status === "failed"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
|
||||
@@ -101,16 +101,15 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
} from "@/workspace/workspace-archive";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
@@ -126,35 +125,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -1574,21 +1544,14 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
@@ -1719,21 +1682,14 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
@@ -2145,33 +2101,11 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
const failed = results.filter(isRejected);
|
||||
if (failed.length > 0) {
|
||||
void archiveWorkspacesOptimistically({
|
||||
client,
|
||||
workspaces: project.workspaces,
|
||||
}).then((failures) => {
|
||||
if (failures.length > 0) {
|
||||
toast.error("Failed to remove some workspaces");
|
||||
}
|
||||
setIsRemovingProject(false);
|
||||
|
||||
@@ -14,17 +14,17 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
|
||||
// ----- Types -----
|
||||
|
||||
export interface ToolCallSheetData {
|
||||
toolName: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
detail?: ToolCallDetail;
|
||||
errorText?: string;
|
||||
icon: ToolCallIconComponent;
|
||||
showLoadingSkeleton?: boolean;
|
||||
}
|
||||
|
||||
@@ -139,9 +139,7 @@ interface ToolCallSheetContentProps {
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
|
||||
|
||||
const IconComponent = resolveToolCallIcon(toolName, detail);
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -148,7 +148,7 @@ export function SearchInput({
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachm
|
||||
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ImageAttachment, MessagePayload } from "./message-input";
|
||||
|
||||
@@ -190,6 +191,14 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
if (target.kind === "agent") {
|
||||
navigateToAgent({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
type WorktreeSetupWorkspaceInput,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
function gitWorkspace(
|
||||
overrides: Partial<WorktreeSetupWorkspaceInput> = {},
|
||||
): WorktreeSetupWorkspaceInput {
|
||||
return {
|
||||
projectId: "project-1",
|
||||
projectKind: "git",
|
||||
projectRootPath: "/repo/project-1",
|
||||
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectActiveGitWorkspaceProject", () => {
|
||||
it("selects the active git workspace project from checkout metadata", () => {
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/main-project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the workspace project root when checkout metadata has no main root", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
|
||||
),
|
||||
).toEqual({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-git workspaces and blank project coordinates", () => {
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })),
|
||||
).toBe(null);
|
||||
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
selectActiveGitWorkspaceProject(
|
||||
"server-1",
|
||||
gitWorkspace({ projectRootPath: " ", project: null }),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowWorktreeSetupCallout", () => {
|
||||
it("shows the callout when paseo config was read and setup commands are missing", () => {
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show the callout when setup commands are present", () => {
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowWorktreeSetupCallout({
|
||||
ok: true,
|
||||
config: { worktree: { setup: [" ", "npm install"] } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the callout when reading paseo config fails or has not completed", () => {
|
||||
expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false);
|
||||
expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorktreeSetupCalloutPolicy", () => {
|
||||
it("builds the stable sidebar callout identity and action route", () => {
|
||||
expect(
|
||||
buildWorktreeSetupCalloutPolicy({
|
||||
serverId: "server-1",
|
||||
projectKey: "project-1",
|
||||
repoRoot: "/repo/project-1",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "worktree-setup-missing:project-1",
|
||||
dismissalKey: "worktree-setup-missing:project-1",
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: "/settings/projects/project-1",
|
||||
testID: "worktree-setup-callout-project-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
85
packages/app/src/components/worktree-setup-callout-policy.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export interface WorktreeSetupWorkspaceInput {
|
||||
projectId: string;
|
||||
projectKind: string;
|
||||
projectRootPath: string;
|
||||
project?: {
|
||||
checkout?: {
|
||||
mainRepoRoot?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ActiveGitWorkspaceProject {
|
||||
serverId: string;
|
||||
projectKey: string;
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
interface ReadProjectConfigResult {
|
||||
ok: boolean;
|
||||
config?: PaseoConfigRaw | null;
|
||||
}
|
||||
|
||||
export interface WorktreeSetupCalloutPolicy {
|
||||
id: string;
|
||||
dismissalKey: string;
|
||||
priority: number;
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
projectSettingsRoute: ReturnType<typeof buildProjectSettingsRoute>;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export function selectActiveGitWorkspaceProject(
|
||||
serverId: string,
|
||||
workspace: WorktreeSetupWorkspaceInput,
|
||||
): 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 };
|
||||
}
|
||||
|
||||
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
|
||||
return readResult?.ok === true && !hasSetupCommands(readResult.config ?? {});
|
||||
}
|
||||
|
||||
export function buildWorktreeSetupCalloutPolicy(
|
||||
project: ActiveGitWorkspaceProject,
|
||||
): WorktreeSetupCalloutPolicy {
|
||||
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
|
||||
|
||||
return {
|
||||
id: calloutKey,
|
||||
dismissalKey: calloutKey,
|
||||
priority: 100,
|
||||
title: "Set up worktree scripts",
|
||||
description:
|
||||
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
|
||||
actionLabel: "Open project settings",
|
||||
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
|
||||
testID: `worktree-setup-callout-${project.projectKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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", () => ({
|
||||
useActiveWorkspaceSelection: () => 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,48 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaseoConfigRaw } from "@server/shared/messages";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } 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;
|
||||
}
|
||||
import {
|
||||
buildWorktreeSetupCalloutPolicy,
|
||||
selectActiveGitWorkspaceProject,
|
||||
shouldShowWorktreeSetupCallout,
|
||||
} from "./worktree-setup-callout-policy";
|
||||
|
||||
export function WorktreeSetupCalloutSource() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
@@ -58,7 +26,7 @@ export function WorktreeSetupCalloutSource() {
|
||||
if (!activeProject) {
|
||||
return;
|
||||
}
|
||||
router.navigate(buildProjectSettingsRoute(activeProject.projectKey));
|
||||
router.navigate(buildWorktreeSetupCalloutPolicy(activeProject).projectSettingsRoute);
|
||||
});
|
||||
|
||||
const readQuery = useQuery({
|
||||
@@ -73,29 +41,31 @@ export function WorktreeSetupCalloutSource() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const shouldShow =
|
||||
activeProject !== null &&
|
||||
readQuery.data?.ok === true &&
|
||||
!hasSetupCommands(readQuery.data.config ?? {});
|
||||
const calloutPolicy = useMemo(
|
||||
() =>
|
||||
activeProject && shouldShowWorktreeSetupCallout(readQuery.data)
|
||||
? buildWorktreeSetupCalloutPolicy(activeProject)
|
||||
: null,
|
||||
[activeProject, readQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow || !activeProject) {
|
||||
if (!calloutPolicy) {
|
||||
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.",
|
||||
id: calloutPolicy.id,
|
||||
dismissalKey: calloutPolicy.dismissalKey,
|
||||
priority: calloutPolicy.priority,
|
||||
title: calloutPolicy.title,
|
||||
description: calloutPolicy.description,
|
||||
actions: [
|
||||
{ label: "Open project settings", onPress: openProjectSettings, variant: "primary" },
|
||||
{ label: calloutPolicy.actionLabel, onPress: openProjectSettings, variant: "primary" },
|
||||
],
|
||||
testID: `worktree-setup-callout-${activeProject.projectKey}`,
|
||||
testID: calloutPolicy.testID,
|
||||
});
|
||||
}, [activeProject, callouts, openProjectSettings, shouldShow]);
|
||||
}, [calloutPolicy, callouts, openProjectSettings]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorage,
|
||||
}));
|
||||
|
||||
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 {
|
||||
SidebarCalloutProvider,
|
||||
type SidebarCalloutsApi,
|
||||
SidebarCalloutViewport,
|
||||
useSidebarCallouts,
|
||||
} from "./sidebar-callout-context";
|
||||
|
||||
const apiSink: { current: SidebarCalloutsApi | null } = { current: null };
|
||||
|
||||
function handleApi(nextApi: SidebarCalloutsApi): void {
|
||||
apiSink.current = nextApi;
|
||||
}
|
||||
|
||||
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
|
||||
const api = useSidebarCallouts();
|
||||
onApi(api);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("SidebarCalloutProvider", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
let api: SidebarCalloutsApi | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = null;
|
||||
apiSink.current = null;
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
api = apiSink.current;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
api = null;
|
||||
});
|
||||
|
||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
||||
act(() => {
|
||||
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
|
||||
api?.show({ id: "update", priority: 200, title: "Update available" });
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Update available");
|
||||
expect(container?.textContent).not.toContain("Set up scripts");
|
||||
|
||||
act(() => {
|
||||
api?.dismiss("update");
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Set up scripts");
|
||||
expect(container?.textContent).not.toContain("Update available");
|
||||
});
|
||||
|
||||
it("replaces a callout by id without duplicating the queue item", () => {
|
||||
act(() => {
|
||||
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
|
||||
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New daemon");
|
||||
expect(container?.textContent).toContain("v2");
|
||||
expect(container?.textContent).not.toContain("Old daemon");
|
||||
});
|
||||
|
||||
it("keeps API consumers from rerendering when callout state changes", () => {
|
||||
const renders = vi.fn();
|
||||
function Producer() {
|
||||
const callouts = useSidebarCallouts();
|
||||
renders(callouts);
|
||||
useEffect(() => {
|
||||
callouts.show({ id: "initial", title: "Initial" });
|
||||
}, [callouts]);
|
||||
return null;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<Producer />
|
||||
<CaptureApi onApi={handleApi} />
|
||||
<SidebarCalloutViewport />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
});
|
||||
api = apiSink.current;
|
||||
const firstApi = renders.mock.calls[0]?.[0];
|
||||
|
||||
act(() => {
|
||||
api?.show({ id: "later", priority: 10, title: "Later" });
|
||||
});
|
||||
|
||||
expect(renders).toHaveBeenCalledTimes(1);
|
||||
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
|
||||
});
|
||||
|
||||
it("unregisters only the registration returned by show", () => {
|
||||
let unregisterOld: (() => void) | null = null;
|
||||
act(() => {
|
||||
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
|
||||
api?.show({ id: "update", title: "New" });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
unregisterOld?.();
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New");
|
||||
});
|
||||
|
||||
it("persists dismissals by dismissal key", () => {
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("Update available");
|
||||
|
||||
act(() => {
|
||||
api?.dismiss("update");
|
||||
});
|
||||
|
||||
expect(container?.textContent).not.toContain("Update available");
|
||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:sidebar-callout-dismissals",
|
||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Dismissed update",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).not.toContain("Dismissed update");
|
||||
|
||||
act(() => {
|
||||
api?.show({
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.4",
|
||||
title: "New update",
|
||||
});
|
||||
});
|
||||
|
||||
expect(container?.textContent).toContain("New update");
|
||||
});
|
||||
});
|
||||
@@ -8,27 +8,24 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
SidebarCallout,
|
||||
type SidebarCalloutAction,
|
||||
type SidebarCalloutProps,
|
||||
type SidebarCalloutVariant,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
clearSidebarCallouts,
|
||||
createSidebarCalloutState,
|
||||
dismissSidebarCallout,
|
||||
loadDismissedCalloutKeys,
|
||||
parseDismissedCalloutKeys,
|
||||
selectActiveSidebarCallout,
|
||||
serializeDismissedCalloutKeys,
|
||||
showSidebarCallout,
|
||||
type SidebarCalloutEntry,
|
||||
type SidebarCalloutOptions,
|
||||
type SidebarCalloutState,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
|
||||
|
||||
export interface SidebarCalloutsApi {
|
||||
show: (callout: SidebarCalloutOptions) => () => void;
|
||||
@@ -36,96 +33,48 @@ export interface SidebarCalloutsApi {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
type SidebarCalloutEntry = SidebarCalloutOptions & {
|
||||
order: number;
|
||||
priority: number;
|
||||
token: number;
|
||||
};
|
||||
|
||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||
|
||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||
|
||||
function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||
const trimmed = key?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||
if (!value) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
|
||||
(error) => {
|
||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function selectActiveCallout(input: {
|
||||
callouts: readonly SidebarCalloutEntry[];
|
||||
dismissedKeys: ReadonlySet<string>;
|
||||
dismissalStorageLoaded: boolean;
|
||||
}): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = input.callouts.filter((entry) => {
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
}
|
||||
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
|
||||
void AsyncStorage.setItem(
|
||||
DISMISSED_CALLOUTS_STORAGE_KEY,
|
||||
serializeDismissedCalloutKeys(keys),
|
||||
).catch((error) => {
|
||||
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
|
||||
});
|
||||
|
||||
if (visibleCallouts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
|
||||
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
|
||||
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
|
||||
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
|
||||
const dismissedKeysRef = useRef<Set<string>>(new Set());
|
||||
const orderRef = useRef(0);
|
||||
const tokenRef = useRef(0);
|
||||
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
|
||||
const stateRef = useRef<SidebarCalloutState>(state);
|
||||
|
||||
function commitState(next: SidebarCalloutState): void {
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
|
||||
.then((value) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = parseDismissedCalloutKeys(value);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
async function loadDismissedKeys(): Promise<void> {
|
||||
let dismissedKeys: ReadonlySet<string>;
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
|
||||
dismissedKeys = parseDismissedCalloutKeys(value);
|
||||
} catch (error) {
|
||||
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setDismissalStorageLoaded(true);
|
||||
}
|
||||
});
|
||||
dismissedKeys = stateRef.current.dismissedKeys;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys));
|
||||
}
|
||||
}
|
||||
|
||||
void loadDismissedKeys();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
@@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const show = useStableEvent((callout: SidebarCalloutOptions) => {
|
||||
tokenRef.current += 1;
|
||||
const token = tokenRef.current;
|
||||
const current = calloutsRef.current;
|
||||
const existing = current.find((entry) => entry.id === callout.id);
|
||||
const nextEntry: SidebarCalloutEntry = {
|
||||
...callout,
|
||||
priority: callout.priority ?? 0,
|
||||
order: existing?.order ?? ++orderRef.current,
|
||||
token,
|
||||
};
|
||||
const next = existing
|
||||
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
||||
: [...current, nextEntry];
|
||||
|
||||
calloutsRef.current = next;
|
||||
setCallouts(next);
|
||||
const result = showSidebarCallout(stateRef.current, callout);
|
||||
commitState(result.state);
|
||||
|
||||
return () => {
|
||||
const updated = calloutsRef.current.filter(
|
||||
(entry) => entry.id !== callout.id || entry.token !== token,
|
||||
commitState(
|
||||
unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }),
|
||||
);
|
||||
calloutsRef.current = updated;
|
||||
setCallouts(updated);
|
||||
};
|
||||
});
|
||||
|
||||
const dismiss = useStableEvent((id: string) => {
|
||||
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
|
||||
const next = calloutsRef.current.filter((entry) => entry.id !== id);
|
||||
calloutsRef.current = next;
|
||||
setCallouts(next);
|
||||
const result = dismissSidebarCallout(stateRef.current, id);
|
||||
commitState(result.state);
|
||||
|
||||
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
|
||||
if (dismissalKey) {
|
||||
const nextKeys = new Set(dismissedKeysRef.current);
|
||||
nextKeys.add(dismissalKey);
|
||||
dismissedKeysRef.current = nextKeys;
|
||||
setDismissedKeys(nextKeys);
|
||||
persistDismissedCalloutKeys(nextKeys);
|
||||
if (result.dismissalKey) {
|
||||
persistDismissedCalloutKeys(result.state.dismissedKeys);
|
||||
}
|
||||
|
||||
dismissed?.onDismiss?.();
|
||||
result.dismissedCallout?.onDismiss?.();
|
||||
});
|
||||
|
||||
const clear = useStableEvent(() => {
|
||||
calloutsRef.current = [];
|
||||
setCallouts([]);
|
||||
commitState(clearSidebarCallouts(stateRef.current));
|
||||
});
|
||||
|
||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||
const activeCallout = useMemo(
|
||||
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
|
||||
[callouts, dismissedKeys, dismissalStorageLoaded],
|
||||
);
|
||||
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||
|
||||
return (
|
||||
<SidebarCalloutApiContext.Provider value={api}>
|
||||
|
||||
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
136
packages/app/src/contexts/sidebar-callout-state.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearSidebarCallouts,
|
||||
createSidebarCalloutState,
|
||||
dismissSidebarCallout,
|
||||
loadDismissedCalloutKeys,
|
||||
parseDismissedCalloutKeys,
|
||||
selectActiveSidebarCallout,
|
||||
serializeDismissedCalloutKeys,
|
||||
showSidebarCallout,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
|
||||
describe("sidebar callout state", () => {
|
||||
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "onboarding",
|
||||
priority: 10,
|
||||
title: "Set up scripts",
|
||||
}).state;
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
priority: 200,
|
||||
title: "Update available",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||
|
||||
state = dismissSidebarCallout(state, "update").state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
|
||||
});
|
||||
|
||||
it("replaces a callout by id without duplicating the queue item", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "daemon",
|
||||
title: "Old daemon",
|
||||
description: "v1",
|
||||
}).state;
|
||||
state = showSidebarCallout(state, {
|
||||
id: "daemon",
|
||||
title: "New daemon",
|
||||
description: "v2",
|
||||
}).state;
|
||||
|
||||
expect(state.callouts).toMatchObject([
|
||||
{
|
||||
id: "daemon",
|
||||
title: "New daemon",
|
||||
description: "v2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("unregisters only the registration returned by show", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" });
|
||||
state = oldRegistration.state;
|
||||
state = showSidebarCallout(state, { id: "update", title: "New" }).state;
|
||||
|
||||
state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token });
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("New");
|
||||
});
|
||||
|
||||
it("persists dismissals by dismissal key and hides matching future callouts", () => {
|
||||
const onDismiss = vi.fn();
|
||||
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set());
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
onDismiss,
|
||||
}).state;
|
||||
|
||||
const result = dismissSidebarCallout(state, "update");
|
||||
state = result.state;
|
||||
|
||||
expect(result.dismissalKey).toBe("desktop-update:available:1.2.3");
|
||||
expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe(
|
||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||
);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
result.dismissedCallout?.onDismiss?.();
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Dismissed update",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.4",
|
||||
title: "New update",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("New update");
|
||||
});
|
||||
|
||||
it("waits for dismissal storage before showing dismissible callouts", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
id: "update",
|
||||
dismissalKey: "desktop-update:available:1.2.3",
|
||||
title: "Update available",
|
||||
}).state;
|
||||
|
||||
expect(selectActiveSidebarCallout(state)).toBeNull();
|
||||
|
||||
state = loadDismissedCalloutKeys(state, new Set());
|
||||
|
||||
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
|
||||
});
|
||||
|
||||
it("parses stored dismissal keys defensively", () => {
|
||||
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
|
||||
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
|
||||
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("clears visible callouts without dropping dismissal state", () => {
|
||||
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
|
||||
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
|
||||
|
||||
state = clearSidebarCallouts(state);
|
||||
|
||||
expect(state.callouts).toEqual([]);
|
||||
expect(state.dismissedKeys).toEqual(new Set(["dismissed"]));
|
||||
});
|
||||
});
|
||||
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
162
packages/app/src/contexts/sidebar-callout-state.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
|
||||
|
||||
export interface SidebarCalloutOptions {
|
||||
id: string;
|
||||
dismissalKey?: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: SidebarCalloutVariant;
|
||||
actions?: readonly SidebarCalloutAction[];
|
||||
dismissible?: boolean;
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface SidebarCalloutEntry extends SidebarCalloutOptions {
|
||||
order: number;
|
||||
priority: number;
|
||||
token: number;
|
||||
}
|
||||
|
||||
export interface SidebarCalloutState {
|
||||
callouts: readonly SidebarCalloutEntry[];
|
||||
dismissedKeys: ReadonlySet<string>;
|
||||
dismissalStorageLoaded: boolean;
|
||||
nextOrder: number;
|
||||
nextToken: number;
|
||||
}
|
||||
|
||||
export function createSidebarCalloutState(): SidebarCalloutState {
|
||||
return {
|
||||
callouts: [],
|
||||
dismissedKeys: new Set(),
|
||||
dismissalStorageLoaded: false,
|
||||
nextOrder: 0,
|
||||
nextToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDismissalKey(key: string | null | undefined): string | null {
|
||||
const trimmed = key?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
|
||||
if (!value) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
|
||||
return JSON.stringify([...keys]);
|
||||
}
|
||||
|
||||
export function loadDismissedCalloutKeys(
|
||||
state: SidebarCalloutState,
|
||||
dismissedKeys: ReadonlySet<string>,
|
||||
): SidebarCalloutState {
|
||||
return {
|
||||
...state,
|
||||
dismissedKeys: new Set(dismissedKeys),
|
||||
dismissalStorageLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function showSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
callout: SidebarCalloutOptions,
|
||||
): { state: SidebarCalloutState; token: number } {
|
||||
const token = state.nextToken + 1;
|
||||
const existing = state.callouts.find((entry) => entry.id === callout.id);
|
||||
const nextEntry: SidebarCalloutEntry = {
|
||||
...callout,
|
||||
priority: callout.priority ?? 0,
|
||||
order: existing?.order ?? state.nextOrder + 1,
|
||||
token,
|
||||
};
|
||||
const callouts = existing
|
||||
? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry))
|
||||
: [...state.callouts, nextEntry];
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
callouts,
|
||||
nextOrder: existing ? state.nextOrder : state.nextOrder + 1,
|
||||
nextToken: token,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
input: { id: string; token: number },
|
||||
): SidebarCalloutState {
|
||||
const callouts = state.callouts.filter(
|
||||
(entry) => entry.id !== input.id || entry.token !== input.token,
|
||||
);
|
||||
return callouts.length === state.callouts.length ? state : { ...state, callouts };
|
||||
}
|
||||
|
||||
export function dismissSidebarCallout(
|
||||
state: SidebarCalloutState,
|
||||
id: string,
|
||||
): {
|
||||
state: SidebarCalloutState;
|
||||
dismissedCallout: SidebarCalloutEntry | null;
|
||||
dismissalKey: string | null;
|
||||
} {
|
||||
const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null;
|
||||
const callouts = state.callouts.filter((entry) => entry.id !== id);
|
||||
const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey);
|
||||
const dismissedKeys = dismissalKey
|
||||
? new Set([...state.dismissedKeys, dismissalKey])
|
||||
: state.dismissedKeys;
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
callouts,
|
||||
dismissedKeys,
|
||||
},
|
||||
dismissedCallout,
|
||||
dismissalKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState {
|
||||
return { ...state, callouts: [] };
|
||||
}
|
||||
|
||||
export function selectActiveSidebarCallout(
|
||||
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||
): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = state.callouts.filter((entry) => {
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
}
|
||||
return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey);
|
||||
});
|
||||
|
||||
if (visibleCallouts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
@@ -172,32 +170,6 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("invalidates checkout PR status and every PR pane timeline for a checkout", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(["checkoutPrStatus", serverId, cwd], { status: { number: 12 } });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 12], { items: [] });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, cwd, 13], { items: [] });
|
||||
queryClient.setQueryData(["prPaneTimeline", serverId, "/tmp/other", 12], { items: [] });
|
||||
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
|
||||
expect(queryClient.getQueryState(["checkoutPrStatus", serverId, cwd])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 12])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(queryClient.getQueryState(["prPaneTimeline", serverId, cwd, 13])?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(["prPaneTimeline", serverId, "/tmp/other", 12])?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
@@ -74,44 +75,6 @@ function setStatus(
|
||||
});
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
{ serverId, cwd }: { serverId: string; cwd: string },
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["checkoutStatus", serverId, cwd],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key[0] === "checkoutPrStatus" &&
|
||||
key[1] === serverId &&
|
||||
key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
|
||||
);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ describe("parseGitHubRepoFromRemote", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo", "acme/repo"],
|
||||
["http://github.com/acme/repo.git", "acme/repo"],
|
||||
["git@github.com:acme/repo.git", "acme/repo"],
|
||||
["ssh://git@github.com/acme/repo.git", "acme/repo"],
|
||||
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
|
||||
["https://github.com/acme/repo/", "acme/repo"],
|
||||
])("extracts the repo from %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
|
||||
// Consolidate into a shared package once we have a third caller.
|
||||
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
|
||||
|
||||
// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config)
|
||||
// are not detected here, so the GitHub action will silently not appear for those remotes.
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleaned = trimmed;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(cleaned);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\/+$/, "");
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return cleaned;
|
||||
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
|
||||
}
|
||||
|
||||
export function buildGitHubBranchTreeUrl(input: {
|
||||
|
||||
62
packages/app/src/git/query-keys.test.ts
Normal file
62
packages/app/src/git/query-keys.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkoutDiffQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo";
|
||||
|
||||
it("invalidates every query for a checkout without touching other checkouts", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
|
||||
items: [],
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
{ items: [] },
|
||||
);
|
||||
|
||||
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
|
||||
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(
|
||||
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
|
||||
)?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
82
packages/app/src/git/query-keys.ts
Normal file
82
packages/app/src/git/query-keys.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutDiff", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function checkoutQueryPredicate(
|
||||
queryKind: CheckoutQueryKey[0],
|
||||
identity: CheckoutQueryIdentity,
|
||||
): (query: Query) => boolean {
|
||||
return (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
isCheckoutQueryKey(key) &&
|
||||
key[0] === queryKind &&
|
||||
key[1] === identity.serverId &&
|
||||
key[2] === identity.cwd
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
|
||||
return (
|
||||
key.length >= 3 &&
|
||||
typeof key[0] === "string" &&
|
||||
typeof key[1] === "string" &&
|
||||
typeof key[2] === "string"
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
|
||||
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
|
||||
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -393,15 +395,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
toast.error("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
navigateToWorkspace(serverId, targetWorkingDir);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const archivedWorkspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: worktreePath,
|
||||
}) ?? worktreePath;
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
}) as Href,
|
||||
);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||
|
||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||
|
||||
@@ -3,16 +3,7 @@ import { useEffect, useId, useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
import { checkoutPrStatusQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutPrStatusQueryOptions {
|
||||
serverId: string;
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { useCheckoutStatusQuery } from "./use-status-query";
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
|
||||
@@ -2,13 +2,10 @@ import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-quer
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("expo-image-manipulator", () => ({
|
||||
SaveFormat: { PNG: "png" },
|
||||
ImageManipulator: {
|
||||
manipulate: (_source: string) => ({
|
||||
async renderAsync() {
|
||||
return {
|
||||
release() {},
|
||||
async saveAsync(options: { format?: string }) {
|
||||
return {
|
||||
uri:
|
||||
options.format === "png"
|
||||
? "file:///cache/ImageManipulator/safe-picked.png"
|
||||
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
release() {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
|
||||
|
||||
describe("native image attachment picker", () => {
|
||||
it("preserves native picked JPEG and PNG attachment inputs", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.JPG",
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpeg",
|
||||
},
|
||||
{
|
||||
uri: "file:///photos/screenshot.png",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpg",
|
||||
},
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.HEIC",
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
|
||||
|
||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpoImagePickerAssetLike {
|
||||
uri: string;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
interface SupportedPickedImageFormat {
|
||||
mimeType: "image/jpeg" | "image/png";
|
||||
extension: "jpg" | "png";
|
||||
}
|
||||
|
||||
const JPEG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/jpeg",
|
||||
extension: "jpg",
|
||||
};
|
||||
|
||||
const PNG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/png",
|
||||
extension: "png",
|
||||
};
|
||||
|
||||
function extensionFromPath(path: string | null | undefined): string | null {
|
||||
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
|
||||
if (extension === "jpg" || extension === "jpeg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (extension === "png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportedFormatForMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const normalizedMimeType = mimeType?.toLowerCase();
|
||||
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (normalizedMimeType === "image/png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickedAssetSupportedFormat(
|
||||
asset: ExpoImagePickerAssetLike,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const uriExtension = extensionFromPath(asset.uri);
|
||||
if (uriExtension) {
|
||||
return supportedFormatForExtension(uriExtension);
|
||||
}
|
||||
|
||||
return (
|
||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
||||
supportedFormatForMimeType(asset.mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
function replaceFileExtension(
|
||||
fileName: string | null | undefined,
|
||||
extension: SupportedPickedImageFormat["extension"],
|
||||
): string | null {
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
|
||||
}
|
||||
|
||||
async function exportPickedImageAsPng(uri: string): Promise<string> {
|
||||
const context = ImageManipulator.manipulate(uri);
|
||||
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
|
||||
|
||||
try {
|
||||
image = await context.renderAsync();
|
||||
const result = await image.saveAsync({
|
||||
format: SaveFormat.PNG,
|
||||
});
|
||||
return result.uri;
|
||||
} finally {
|
||||
image?.release();
|
||||
context.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[],
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const supportedFormat = pickedAssetSupportedFormat(asset);
|
||||
if (supportedFormat) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: supportedFormat.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
|
||||
};
|
||||
}
|
||||
|
||||
const convertedUri = await exportPickedImageAsPng(asset.uri);
|
||||
|
||||
return {
|
||||
source: { kind: "file_uri", uri: convertedUri },
|
||||
mimeType: PNG_FORMAT.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
||||
throw new Error("Desktop dialog API is not available on native.");
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
@@ -11,15 +10,14 @@ import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostAgentDetailRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
|
||||
@@ -204,18 +202,9 @@ export function useCommandCenter() {
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
});
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
|
||||
return;
|
||||
}
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId: agent.serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
agentId: agent.id,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,11 +12,8 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} from "@server/shared/messages";
|
||||
import {
|
||||
prPaneTimelineQueryKey,
|
||||
usePrPaneData,
|
||||
type UsePrPaneDataResult,
|
||||
} from "./use-pr-pane-data";
|
||||
import { checkoutPrStatusQueryKey, prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
import { usePrPaneData, type UsePrPaneDataResult } from "./use-pr-pane-data";
|
||||
import { useWorkspacePrHint } from "@/git/use-pr-status-query";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
@@ -335,7 +332,7 @@ describe("usePrPaneData", () => {
|
||||
).toBeUndefined();
|
||||
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
|
||||
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(mockClient.pullRequestTimeline).toHaveBeenCalledWith({
|
||||
@@ -434,7 +431,7 @@ describe("usePrPaneData", () => {
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.checks[0]?.status).toBe("success");
|
||||
});
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(
|
||||
statusPayload({
|
||||
requestId: "server-push",
|
||||
status: status({
|
||||
@@ -474,8 +471,10 @@ describe("usePrPaneData", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(initial);
|
||||
expect(
|
||||
queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, "/other-repo")),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes repoOwner and repoName to the timeline request when present", async () => {
|
||||
@@ -719,7 +718,7 @@ describe("usePrPaneData", () => {
|
||||
|
||||
const refreshDeferred = createDeferred<CheckoutPrStatusPayload>();
|
||||
mockClient.checkoutPrStatus.mockReturnValue(refreshDeferred.promise);
|
||||
hook.queryClient.invalidateQueries({ queryKey: ["checkoutPrStatus", serverId, cwd] });
|
||||
hook.queryClient.invalidateQueries({ queryKey: checkoutPrStatusQueryKey(serverId, cwd) });
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.isLoading).toBe(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "@server/shared/messages";
|
||||
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -152,18 +153,6 @@ export function usePrPaneData({
|
||||
};
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
function firstNonSuppressedError({
|
||||
statusPayloadError,
|
||||
statusError,
|
||||
|
||||
@@ -148,9 +148,16 @@ describe("keyboard-shortcuts", () => {
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on desktop via Alt+digit",
|
||||
name: "matches tab index jump on mac desktop via Cmd+Alt+digit",
|
||||
event: { key: "@", code: "Digit2", metaKey: true, altKey: true },
|
||||
context: { isMac: true, isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
{
|
||||
name: "matches tab index jump on non-mac desktop via Alt+digit",
|
||||
event: { key: "2", code: "Digit2", altKey: true },
|
||||
context: { isDesktop: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
action: "workspace.tab.navigate.index",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
@@ -333,6 +340,11 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "t", code: "KeyT", ctrlKey: true },
|
||||
context: { isMac: true },
|
||||
},
|
||||
{
|
||||
name: "keeps mac Option+digit available for international text input",
|
||||
event: { key: "@", code: "Digit2", altKey: true },
|
||||
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not match Ctrl+K for command center on non-mac in terminal",
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
@@ -477,16 +489,17 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||
"workspace-tab-close-current": ["meta", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shows Ctrl+W close tab for non-mac desktop",
|
||||
name: "uses non-mac desktop defaults for tab jump and close tab",
|
||||
context: { isMac: false, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"workspace-tab-jump-index": ["alt", "1-9"],
|
||||
"workspace-tab-close-current": ["ctrl", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -292,11 +292,24 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
|
||||
// --- Tab index jump ---
|
||||
{
|
||||
id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Cmd+Alt+Digit",
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["mod", "alt", "1-9"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-digit-desktop",
|
||||
action: "workspace.tab.navigate.index",
|
||||
combo: "Alt+Digit",
|
||||
when: { desktop: true, commandCenter: false },
|
||||
when: { mac: false, desktop: true, commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
|
||||
@@ -58,6 +58,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
interface ChatAgentStateShape {
|
||||
@@ -1258,14 +1259,14 @@ function ActiveAgentComposer({
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId } = paneContext;
|
||||
const subagentRows = useSubagentsForParent({
|
||||
serverId: paneContext.serverId,
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
paneContext.openTab({ kind: "agent", agentId: subagentId });
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
},
|
||||
[paneContext],
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
|
||||
40
packages/app/src/polyfills/crypto.test.ts
Normal file
40
packages/app/src/polyfills/crypto.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const expoCryptoMock = vi.hoisted(() => ({
|
||||
getRandomValues: vi.fn(<T extends ArrayBufferView>(array: T): T => array),
|
||||
randomUUID: vi.fn(() => {
|
||||
throw new Error("ExpoCrypto.randomUUID should not be used for the web fallback");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("expo-crypto", () => expoCryptoMock);
|
||||
|
||||
describe("polyfillCrypto", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
expoCryptoMock.getRandomValues.mockClear();
|
||||
expoCryptoMock.randomUUID.mockClear();
|
||||
});
|
||||
|
||||
it("generates randomUUID from getRandomValues when Web Crypto randomUUID is unavailable", async () => {
|
||||
const sourceBytes = Uint8Array.from([
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
|
||||
0xff,
|
||||
]);
|
||||
const getRandomValues = vi.fn(<T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array && ArrayBuffer.isView(array)) {
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(sourceBytes);
|
||||
}
|
||||
return array;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
|
||||
const { polyfillCrypto } = await import("./crypto");
|
||||
polyfillCrypto();
|
||||
|
||||
expect(globalThis.crypto.randomUUID()).toBe("00112233-4455-4677-8899-aabbccddeeff");
|
||||
expect(getRandomValues).toHaveBeenCalledTimes(1);
|
||||
expect(expoCryptoMock.randomUUID).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,26 @@ interface MutableGlobal {
|
||||
crypto?: Crypto;
|
||||
}
|
||||
|
||||
type RandomUUID = `${string}-${string}-${string}-${string}-${string}`;
|
||||
type FillRandomValues = <T extends ArrayBufferView | null>(array: T) => T;
|
||||
|
||||
function createUuidV4(fillRandomValues: FillRandomValues): RandomUUID {
|
||||
const bytes = fillRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
|
||||
.slice(6, 8)
|
||||
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}` as RandomUUID;
|
||||
}
|
||||
|
||||
export function polyfillCrypto(): void {
|
||||
const g = globalThis as unknown as MutableGlobal;
|
||||
const nativeGetRandomValues =
|
||||
typeof g.crypto?.getRandomValues === "function"
|
||||
? g.crypto.getRandomValues.bind(g.crypto)
|
||||
: null;
|
||||
|
||||
// Ensure TextEncoder/TextDecoder exist for shared E2EE code (tweetnacl + relay transport).
|
||||
// Hermes may not provide them in all configurations.
|
||||
@@ -47,17 +65,21 @@ export function polyfillCrypto(): void {
|
||||
g.crypto = {} as Crypto;
|
||||
}
|
||||
|
||||
const fillRandomValues: FillRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array === null) return array;
|
||||
if (nativeGetRandomValues) {
|
||||
return nativeGetRandomValues(array as unknown as ArrayBufferView<ArrayBuffer>) as T;
|
||||
}
|
||||
return ExpoCrypto.getRandomValues(
|
||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||
) as unknown as T;
|
||||
};
|
||||
|
||||
if (typeof g.crypto.randomUUID !== "function") {
|
||||
g.crypto.randomUUID = () =>
|
||||
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
|
||||
g.crypto.randomUUID = () => createUuidV4(fillRandomValues);
|
||||
}
|
||||
|
||||
if (typeof g.crypto.getRandomValues !== "function") {
|
||||
g.crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array === null) return array;
|
||||
return ExpoCrypto.getRandomValues(
|
||||
array as unknown as Parameters<typeof ExpoCrypto.getRandomValues>[0],
|
||||
) as unknown as T;
|
||||
};
|
||||
g.crypto.getRandomValues = fillRandomValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,15 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
||||
};
|
||||
}
|
||||
|
||||
function encodeOfferUrl(payload: unknown): string {
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
return `https://app.paseo.sh/#offer=${encoded}`;
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[],
|
||||
@@ -1734,6 +1743,41 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const oldPairingUrl = encodeOfferUrl({
|
||||
v: 2,
|
||||
serverId: "srv_offer",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
{
|
||||
id: "relay:wss:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
]);
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
|
||||
@@ -1506,10 +1506,12 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls: offer.relay.useTls,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -639,6 +640,9 @@ export function NewWorkspaceScreen({
|
||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
|
||||
const renderPickerOption = useCallback(
|
||||
({
|
||||
@@ -713,82 +717,84 @@ export function NewWorkspaceScreen({
|
||||
: "No matching refs.";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
leftStyle={styles.headerLeft}
|
||||
borderless
|
||||
/>
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centered}>
|
||||
<Composer
|
||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel="Create"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||
<View style={styles.container}>
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
leftStyle={styles.headerLeft}
|
||||
borderless
|
||||
/>
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.centered}>
|
||||
<Composer
|
||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel="Create"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={statusControlsWithDisabled}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
|
||||
<View>
|
||||
<RefPickerTrigger
|
||||
pickerAnchorRef={pickerAnchorRef}
|
||||
onPress={openPicker}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
selectedItem={selectedItem}
|
||||
triggerLabel={triggerLabel}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedOptionId}
|
||||
onSelect={handleSelectOption}
|
||||
searchable
|
||||
searchPlaceholder="Search branches and PRs"
|
||||
title="Start from"
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
onSearchQueryChange={setPickerSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={pickerAnchorRef}
|
||||
emptyText={pickerEmptyText}
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -720,9 +720,16 @@ function SettingsSidebar({
|
||||
}, [hosts, localServerId]);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const isDesktop = layout === "desktop";
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
|
||||
isDesktop ? { paddingTop: insets.top } : null,
|
||||
],
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
|
||||
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
99
packages/app/src/screens/workspace/terminals/state.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
type ListTerminalsPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
import type { CreateTerminalResponse } from "@server/shared/messages";
|
||||
|
||||
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
|
||||
return { id, name: id, title: id };
|
||||
}
|
||||
|
||||
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
|
||||
return { id, name: id, cwd: "/repo", title: id };
|
||||
}
|
||||
|
||||
describe("workspace terminal state", () => {
|
||||
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
|
||||
const pending = new Map([
|
||||
["older-than-list", 10],
|
||||
["now-live", 20],
|
||||
["still-pending", 30],
|
||||
]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
|
||||
|
||||
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
|
||||
});
|
||||
|
||||
it("returns the same pending map when reconciliation changes nothing", () => {
|
||||
const pending = new Map([["still-pending", 30]]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
|
||||
|
||||
expect(reconciled).toBe(pending);
|
||||
});
|
||||
|
||||
it("combines live and pending terminal ids without duplicating script terminals", () => {
|
||||
const pendingScriptTerminalIds = new Map([
|
||||
["script-pending", 10],
|
||||
["terminal-1", 10],
|
||||
]);
|
||||
|
||||
expect(
|
||||
collectKnownTerminalIds({
|
||||
liveTerminalIds: ["terminal-1", "terminal-2"],
|
||||
pendingScriptTerminalIds,
|
||||
}),
|
||||
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
|
||||
expect(
|
||||
collectScriptTerminalIds({
|
||||
pendingScriptTerminalIds,
|
||||
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
|
||||
}),
|
||||
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
|
||||
expect(
|
||||
collectStandaloneTerminalIds({
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
listedTerminal("terminal-2"),
|
||||
listedTerminal("script-live"),
|
||||
],
|
||||
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
|
||||
}),
|
||||
).toEqual(["terminal-2"]);
|
||||
});
|
||||
|
||||
it("updates terminal cache entries for created and closed terminals", () => {
|
||||
const current: ListTerminalsPayload = {
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [listedTerminal("terminal-1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal("terminal-2"),
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
|
||||
],
|
||||
});
|
||||
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
106
packages/app/src/screens/workspace/terminals/state.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
|
||||
export const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
|
||||
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type TerminalEntry = ListTerminalsPayload["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
|
||||
return ["terminals", serverId, workspaceDirectory] as const;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceTerminal(input: {
|
||||
isRouteFocused: boolean;
|
||||
client: unknown;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
|
||||
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
};
|
||||
}
|
||||
|
||||
export function collectKnownTerminalIds(input: {
|
||||
liveTerminalIds: string[];
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
}): string[] {
|
||||
const terminalIds = new Set(input.liveTerminalIds);
|
||||
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}
|
||||
|
||||
export function collectScriptTerminalIds(input: {
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
scripts: Array<{ terminalId?: string | null }>;
|
||||
}): Set<string> {
|
||||
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
|
||||
for (const script of input.scripts) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}
|
||||
|
||||
export function collectStandaloneTerminalIds(input: {
|
||||
terminals: TerminalEntry[];
|
||||
scriptTerminalIds: Set<string>;
|
||||
}): string[] {
|
||||
return input.terminals
|
||||
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id);
|
||||
}
|
||||
|
||||
export function removeTerminalFromPayload(terminalId: string) {
|
||||
return function updatePayload(
|
||||
current: ListTerminalsPayload | undefined,
|
||||
): ListTerminalsPayload | undefined {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertCreatedTerminalPayload(input: {
|
||||
current: ListTerminalsPayload | undefined;
|
||||
terminal: CreatedTerminal;
|
||||
workspaceDirectory: string | null;
|
||||
}): ListTerminalsPayload {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: input.current?.terminals ?? [],
|
||||
terminal: input.terminal,
|
||||
});
|
||||
const cwd = input.current?.cwd ?? input.workspaceDirectory;
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildTerminalsQueryKey,
|
||||
canCreateWorkspaceTerminal,
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
TERMINALS_QUERY_STALE_TIME,
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
|
||||
interface PendingTerminalCreateInput {
|
||||
paneId?: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceTerminalsInput {
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
hasHydratedWorkspaces: boolean;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
onScriptTerminalSelected: (terminalId: string) => void;
|
||||
onWorkspacePathUnavailable: () => void;
|
||||
onTerminalCreateQueued: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
const {
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated,
|
||||
onScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued,
|
||||
} = input;
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
|
||||
null,
|
||||
);
|
||||
const canCreateNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: canCreateNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
|
||||
const dataUpdatedAt = query.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
|
||||
}, [liveTerminalIds, dataUpdatedAt]);
|
||||
|
||||
const knownTerminalIds = useMemo(
|
||||
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
|
||||
[liveTerminalIds, pendingScriptTerminalIds],
|
||||
);
|
||||
const scriptTerminalIds = useMemo(
|
||||
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
|
||||
[pendingScriptTerminalIds, workspaceScripts],
|
||||
);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (_input?: PendingTerminalCreateInput) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, createInput) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
if (createdTerminal) {
|
||||
onTerminalCreated({
|
||||
terminalId: createdTerminal.id,
|
||||
paneId: createInput?.paneId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const killMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow && !createMutation.isPending) {
|
||||
const pendingInput = pendingCreateInput;
|
||||
setPendingCreateInput(null);
|
||||
createMutation.mutate(pendingInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
setPendingCreateInput(null);
|
||||
onWorkspacePathUnavailable();
|
||||
}
|
||||
}, [
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
]);
|
||||
|
||||
const createTerminal = useCallback(
|
||||
(createInput?: PendingTerminalCreateInput) => {
|
||||
if (createMutation.isPending || pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow) {
|
||||
createMutation.mutate(createInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
onWorkspacePathUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingCreateInput(createInput ?? {});
|
||||
onTerminalCreateQueued();
|
||||
},
|
||||
[
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreateQueued,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
onScriptTerminalSelected(terminalId);
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
|
||||
);
|
||||
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
onScriptTerminalSelected(terminalId);
|
||||
},
|
||||
[onScriptTerminalSelected],
|
||||
);
|
||||
|
||||
const removeTerminalFromCache = useCallback(
|
||||
(terminalId: string) => {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
queryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
},
|
||||
[queryClient, queryKey],
|
||||
);
|
||||
|
||||
const invalidateTerminals = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput,
|
||||
query,
|
||||
queryKey,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -92,9 +92,8 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -132,9 +131,10 @@ import {
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
@@ -156,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
@@ -1026,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
type PaneDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||
@@ -1178,39 +1178,6 @@ function resolveWorkspaceAuthorityState(
|
||||
};
|
||||
}
|
||||
|
||||
function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
|
||||
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
};
|
||||
}
|
||||
|
||||
function removeTerminalFromPayload(terminalId: string) {
|
||||
return function updatePayload(
|
||||
current: ListTerminalsPayload | undefined,
|
||||
): ListTerminalsPayload | undefined {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
||||
const trimmed = host?.label?.trim();
|
||||
return trimmed ? trimmed : fallback;
|
||||
@@ -1357,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: {
|
||||
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||
}
|
||||
|
||||
function canCreateWorkspaceTerminal(input: {
|
||||
isRouteFocused: boolean;
|
||||
client: unknown;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
@@ -1375,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActionsInput {
|
||||
persistenceKey: string | null;
|
||||
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
|
||||
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
toast: {
|
||||
error: (message: string) => void;
|
||||
show: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalTabActions {
|
||||
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
handleScriptTerminalSelected: (terminalId: string) => void;
|
||||
handleWorkspacePathUnavailable: () => void;
|
||||
handleTerminalCreateQueued: () => void;
|
||||
}
|
||||
|
||||
function useWorkspaceTerminalTabActions({
|
||||
persistenceKey,
|
||||
focusWorkspacePane,
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
|
||||
const handleTerminalCreated = useCallback(
|
||||
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (paneId) {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const handleScriptTerminalSelected = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const handleWorkspacePathUnavailable = useCallback(() => {
|
||||
toast.error("Workspace path is not available yet");
|
||||
}, [toast]);
|
||||
const handleTerminalCreateQueued = useCallback(() => {
|
||||
toast.show("Preparing workspace, opening terminal when ready...");
|
||||
}, [toast]);
|
||||
|
||||
return {
|
||||
handleTerminalCreated,
|
||||
handleScriptTerminalSelected,
|
||||
handleWorkspacePathUnavailable,
|
||||
handleTerminalCreateQueued,
|
||||
};
|
||||
}
|
||||
|
||||
function useWorkspaceCheckoutStatus(input: {
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}) {
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() =>
|
||||
canCreateWorkspaceTerminal({
|
||||
isRouteFocused: input.isRouteFocused,
|
||||
client: input.client,
|
||||
isConnected: input.isConnected,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
}),
|
||||
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
input.normalizedServerId,
|
||||
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!input.client || !input.workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await input.client.getCheckoutStatus(input.workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||
);
|
||||
|
||||
return { checkoutQuery, isCheckoutStatusLoading };
|
||||
}
|
||||
|
||||
function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -1403,7 +1461,6 @@ function WorkspaceScreenContent({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceAuthority = useMemo(
|
||||
@@ -1428,12 +1485,19 @@ function WorkspaceScreenContent({
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||
paneId?: string;
|
||||
} | null>(null);
|
||||
const canCreateTerminalNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
@@ -1447,167 +1511,56 @@ function WorkspaceScreenContent({
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
const terminalsQueryKey = useMemo(
|
||||
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: canCreateTerminalNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
const {
|
||||
handleTerminalCreated,
|
||||
handleScriptTerminalSelected,
|
||||
handleWorkspacePathUnavailable,
|
||||
handleTerminalCreateQueued,
|
||||
} = useWorkspaceTerminalTabActions({
|
||||
persistenceKey,
|
||||
focusWorkspacePane,
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
});
|
||||
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(
|
||||
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
|
||||
);
|
||||
}, [liveTerminalIds, terminalsDataUpdatedAt]);
|
||||
const knownTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(liveTerminalIds);
|
||||
for (const terminalId of pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}, [liveTerminalIds, pendingScriptTerminalIds]);
|
||||
const scriptTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(pendingScriptTerminalIds.keys());
|
||||
for (const script of workspaceDescriptor?.scripts ?? []) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() =>
|
||||
terminals
|
||||
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async (_input?: { paneId?: string }) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, input) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
const cwd = current?.cwd ?? workspaceDirectory;
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
if (createdTerminal) {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
if (input?.paneId) {
|
||||
focusWorkspacePane(workspaceKey, input.paneId);
|
||||
}
|
||||
useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
const killTerminalMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
const {
|
||||
createMutation: createTerminalMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation: killTerminalMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput: pendingTerminalCreateInput,
|
||||
query: terminalsQuery,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
} = useWorkspaceTerminals({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated: handleTerminalCreated,
|
||||
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued: handleTerminalCreateQueued,
|
||||
});
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||
|
||||
const isCheckoutQueryEnabled = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(
|
||||
normalizedServerId,
|
||||
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
||||
),
|
||||
enabled: isCheckoutQueryEnabled,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.getCheckoutStatus(workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
const isCheckoutStatusLoading = useMemo(
|
||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const hasHydratedAgents = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
||||
);
|
||||
@@ -1616,30 +1569,6 @@ function WorkspaceScreenContent({
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!pendingTerminalCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
|
||||
const pendingInput = pendingTerminalCreateInput;
|
||||
setPendingTerminalCreateInput(null);
|
||||
createTerminalMutation.mutate(pendingInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
setPendingTerminalCreateInput(null);
|
||||
toast.error("Workspace path is not available yet");
|
||||
}
|
||||
}, [
|
||||
canCreateTerminalNow,
|
||||
createTerminalMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
pendingTerminalCreateInput,
|
||||
toast,
|
||||
]);
|
||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||
isCheckoutStatusLoading,
|
||||
isError: checkoutQuery.isError,
|
||||
@@ -1736,15 +1665,6 @@ function WorkspaceScreenContent({
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
||||
|
||||
const persistenceKey = useMemo(
|
||||
() =>
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
|
||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||
);
|
||||
@@ -1759,7 +1679,6 @@ function WorkspaceScreenContent({
|
||||
[workspaceLayout],
|
||||
);
|
||||
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
||||
(state) => state.openTabInBackground,
|
||||
);
|
||||
@@ -1775,39 +1694,6 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
if (persistenceKey) {
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
},
|
||||
[
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQuery.dataUpdatedAt,
|
||||
terminalsQueryKey,
|
||||
],
|
||||
);
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -1918,16 +1804,17 @@ function WorkspaceScreenContent({
|
||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||
});
|
||||
|
||||
reconcileWorkspaceTabs(persistenceKey, {
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
|
||||
autoOpenAgentIds: Array.from(workspaceAgentVisibility.autoOpenAgentIds),
|
||||
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
});
|
||||
reconcileWorkspaceTabs(
|
||||
persistenceKey,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: workspaceAgentVisibility,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
hasHydratedAgents,
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
@@ -2197,24 +2084,7 @@ function WorkspaceScreenContent({
|
||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
|
||||
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateTerminalNow) {
|
||||
createTerminalMutation.mutate(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
toast.error("Workspace path is not available yet");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTerminalCreateInput(input ?? {});
|
||||
toast.show("Preparing workspace, opening terminal when ready...");
|
||||
});
|
||||
const handleCreateTerminal = useStableEvent(createTerminal);
|
||||
|
||||
const handleCreateBrowserTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
@@ -2281,10 +2151,7 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
removeTerminalFromCache(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
@@ -2294,18 +2161,16 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
|
||||
void killTerminalAsync(terminalId).catch(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
});
|
||||
void killTerminalAsync(terminalId).catch(invalidateTerminals);
|
||||
});
|
||||
},
|
||||
[
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
invalidateTerminals,
|
||||
killTerminalAsync,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
removeTerminalFromCache,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
@@ -86,7 +88,7 @@ interface InsertSplitInternalInput {
|
||||
targetPaneId: string;
|
||||
tabId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
}
|
||||
|
||||
interface InsertSplitInternalResult {
|
||||
@@ -142,7 +144,7 @@ interface SplitPaneInLayoutInput {
|
||||
tabId: string;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -155,7 +157,7 @@ interface SplitPaneEmptyInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
targetPaneId: string;
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
createNodeId: (prefix: "pane" | "group") => string;
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
maxTreeDepth: number;
|
||||
}
|
||||
|
||||
@@ -232,14 +234,6 @@ function normalizeTabIds(list: unknown): string[] {
|
||||
return next;
|
||||
}
|
||||
|
||||
function generateNodeId(prefix: "pane" | "group"): string {
|
||||
const randomValue =
|
||||
typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}_${randomValue}`;
|
||||
}
|
||||
|
||||
function createPaneNode(input: {
|
||||
id: string;
|
||||
tabs?: WorkspaceTab[];
|
||||
@@ -996,13 +990,16 @@ export function insertSplit(
|
||||
targetPaneId: string,
|
||||
tabId: string,
|
||||
position: "left" | "right" | "top" | "bottom",
|
||||
createNodeId: (
|
||||
prefix: WorkspaceLayoutNodeIdPrefix,
|
||||
) => string = defaultWorkspaceLayoutIds.createNodeId,
|
||||
): SplitNode {
|
||||
return insertSplitInternal({
|
||||
root: asInternalNode(root),
|
||||
targetPaneId,
|
||||
tabId,
|
||||
position,
|
||||
createNodeId: generateNodeId,
|
||||
createNodeId,
|
||||
}).root;
|
||||
}
|
||||
|
||||
|
||||
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
17
packages/app/src/stores/workspace-layout-ids.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type WorkspaceLayoutNodeIdPrefix = "pane" | "group";
|
||||
|
||||
export interface WorkspaceLayoutIdSource {
|
||||
createNodeId: (prefix: WorkspaceLayoutNodeIdPrefix) => string;
|
||||
createFocusRestorationToken: () => string;
|
||||
}
|
||||
|
||||
function createRandomIdValue(): string {
|
||||
return typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export const defaultWorkspaceLayoutIds: WorkspaceLayoutIdSource = {
|
||||
createNodeId: (prefix) => `${prefix}_${createRandomIdValue()}`,
|
||||
createFocusRestorationToken: () => `workspace-focus-${createRandomIdValue()}`,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutStore,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
insertSplit,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -36,6 +36,40 @@ import {
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "ws-main";
|
||||
|
||||
function createDeterministicWorkspaceLayoutIds() {
|
||||
let values: string[] = [];
|
||||
let fallbackIndex = 0;
|
||||
|
||||
function nextValue(): string {
|
||||
const value = values.shift();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
fallbackIndex += 1;
|
||||
return `generated-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
return {
|
||||
useValues: (nextValues: string[]) => {
|
||||
values = nextValues.slice();
|
||||
fallbackIndex = 0;
|
||||
},
|
||||
reset: () => {
|
||||
values = [];
|
||||
fallbackIndex = 0;
|
||||
},
|
||||
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
|
||||
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
|
||||
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
|
||||
|
||||
function useWorkspaceLayoutIds(...values: string[]) {
|
||||
workspaceLayoutIds.useValues(values);
|
||||
}
|
||||
|
||||
function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab {
|
||||
return {
|
||||
tabId,
|
||||
@@ -168,13 +202,14 @@ describe("workspace-layout-store helpers", () => {
|
||||
|
||||
describe("workspace-layout-store tree transforms", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
workspaceLayoutIds.reset();
|
||||
});
|
||||
|
||||
it("insertSplit wraps root-level same-direction splits in a nested group", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
);
|
||||
|
||||
const root: SplitNode = {
|
||||
kind: "group",
|
||||
@@ -189,7 +224,7 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right");
|
||||
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
|
||||
const nextGroup = expectGroup(nextRoot);
|
||||
const nestedGroup = expectGroup(nextGroup.group.children[1]);
|
||||
|
||||
@@ -270,22 +305,20 @@ describe("workspace-layout-store tree transforms", () => {
|
||||
|
||||
describe("workspace-layout-store actions", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
workspaceLayoutIds.reset();
|
||||
workspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
);
|
||||
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -308,7 +341,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
||||
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
||||
@@ -322,14 +355,14 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground inserts a tab without stealing focus", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const agentTabId = store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const setupTabId = store.openTabInBackground(workspaceKey, {
|
||||
kind: "setup",
|
||||
workspaceId: "ws-main",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layout.root, "main")!;
|
||||
|
||||
expect(agentTabId).toBe("agent_agent-1");
|
||||
@@ -341,7 +374,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabInBackground on an existing target is a no-op", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -355,7 +388,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/a.ts",
|
||||
});
|
||||
const layoutAfter = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneById(layoutAfter.root, "main")!;
|
||||
|
||||
expect(duplicateTabId).toBe(firstTabId);
|
||||
@@ -365,27 +398,25 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("unfocuses and restores the previous focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const token = store.unfocusPane(workspaceKey);
|
||||
expect(token).toBeTruthy();
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore stale focus after another pane is focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.splitPane(workspaceKey, {
|
||||
@@ -399,14 +430,14 @@ describe("workspace-layout-store actions", () => {
|
||||
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
store.restorePaneFocus(workspaceKey, token!);
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for nested focus restorations before restoring", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const outerToken = store.unfocusPane(workspaceKey);
|
||||
@@ -414,22 +445,22 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.restorePaneFocus(workspaceKey, outerToken!);
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
||||
).toBeNull();
|
||||
|
||||
store.restorePaneFocus(workspaceKey, innerToken!);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
||||
"main",
|
||||
);
|
||||
});
|
||||
|
||||
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstTabId).toBe("draft-1");
|
||||
expect(secondTabId).toBe("draft-2");
|
||||
@@ -450,11 +481,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
);
|
||||
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
@@ -465,7 +494,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "draft",
|
||||
draftId: "draft-split",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||
expect(draftTabId).toBe("draft-split");
|
||||
@@ -476,11 +505,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
);
|
||||
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const fileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -497,22 +524,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusTab(workspaceKey, fileTabId!);
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusTab(workspaceKey, terminalTabId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212",
|
||||
);
|
||||
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
@@ -523,7 +548,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const splitPane = findPaneById(layout.root, splitPaneId);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
@@ -540,7 +565,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab keeps a draft tab in place while updating its target", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -550,7 +575,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/retargeted.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(draftTabId).toBe("draft-retarget");
|
||||
expect(nextTabId).toBe(draftTabId);
|
||||
@@ -565,11 +590,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
);
|
||||
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const existingFileTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -590,7 +613,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||
expect(draftTabId).toBe("draft-dup");
|
||||
@@ -606,7 +629,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("retargetTab closes a draft tab and focuses an existing matching target tab", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstDraftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -625,7 +648,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(firstAgentTabId).toBe(firstDraftTabId);
|
||||
expect(nextTabId).toBe(firstDraftTabId);
|
||||
@@ -641,7 +664,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -657,7 +680,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(findPaneById(layout.root, "main")).toEqual({
|
||||
id: "main",
|
||||
@@ -684,11 +707,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"34343434-3434-3434-3434-343434343434",
|
||||
);
|
||||
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.openTabFocused(workspaceKey, {
|
||||
@@ -712,7 +733,7 @@ describe("workspace-layout-store actions", () => {
|
||||
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
||||
store.focusPane(workspaceKey, "main");
|
||||
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -736,11 +757,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("focusPane switches workspace focus to a different pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"56565656-5656-5656-5656-565656565656",
|
||||
);
|
||||
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -754,22 +773,20 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.focusPane(workspaceKey, "main");
|
||||
let layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
|
||||
store.focusPane(workspaceKey, splitPaneId!);
|
||||
layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
);
|
||||
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -783,7 +800,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
@@ -791,18 +808,19 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("splitPane enforces the maximum depth of four", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222")
|
||||
.mockReturnValueOnce("33333333-3333-3333-3333-333333333333")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
useWorkspaceLayoutIds(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
"22222222-2222-2222-2222-222222222222",
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
"44444444-4444-4444-4444-444444444444",
|
||||
"55555555-5555-5555-5555-555555555555",
|
||||
"66666666-6666-6666-6666-666666666666",
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
"88888888-8888-8888-8888-888888888888",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const c = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/c.ts" });
|
||||
@@ -831,7 +849,7 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "bottom",
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
||||
expect(pane2).toBe("pane_33333333-3333-3333-3333-333333333333");
|
||||
expect(pane3).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
@@ -840,11 +858,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
);
|
||||
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const leftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "file",
|
||||
@@ -861,7 +877,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
||||
@@ -872,13 +888,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("78787878-7878-7878-7878-787878787878")
|
||||
.mockReturnValueOnce("89898989-8989-8989-8989-898989898989")
|
||||
.mockReturnValueOnce("9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a");
|
||||
useWorkspaceLayoutIds(
|
||||
"78787878-7878-7878-7878-787878787878",
|
||||
"89898989-8989-8989-8989-898989898989",
|
||||
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -901,7 +918,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
store.closeTab(workspaceKey, secondTabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const rootGroup = expectGroup(layout.root);
|
||||
|
||||
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
||||
@@ -937,11 +954,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"abababab-abab-abab-abab-abababababab",
|
||||
);
|
||||
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTabFocused(workspaceKey, {
|
||||
@@ -959,7 +974,7 @@ describe("workspace-layout-store actions", () => {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/b.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
||||
expect(duplicateTabId).toBe(secondTabId);
|
||||
@@ -971,13 +986,14 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")
|
||||
.mockReturnValueOnce("ffffffff-ffff-ffff-ffff-ffffffffffff")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111");
|
||||
useWorkspaceLayoutIds(
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const a = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const b = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
@@ -995,12 +1011,12 @@ describe("workspace-layout-store actions", () => {
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const splitRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const splitGroup = expectGroup(splitRoot);
|
||||
const nestedGroup = expectGroup(splitGroup.group.children[1]);
|
||||
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
||||
|
||||
const resizedRoot = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
||||
const resizedGroup = expectGroup(resizedRoot);
|
||||
const resizedNestedGroup = expectGroup(resizedGroup.group.children[1]);
|
||||
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
||||
@@ -1014,11 +1030,11 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("closing the last tab keeps a single empty pane in the layout", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "draft", draftId: "draft-1" });
|
||||
store.closeTab(workspaceKey, tabId!);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(layout).toEqual(createDefaultLayout());
|
||||
});
|
||||
@@ -1032,12 +1048,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
store.pinAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1045,13 +1061,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unpinAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1068,12 +1084,12 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
expect(otherWorkspaceKey).toBeTruthy();
|
||||
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.hideAgent(otherWorkspaceKey as string, "agent-2");
|
||||
|
||||
let state = useWorkspaceLayoutStore.getState();
|
||||
let state = workspaceLayoutStore.getState();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
@@ -1081,13 +1097,13 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
store.unhideAgent(workspaceKey, "agent-1");
|
||||
|
||||
state = useWorkspaceLayoutStore.getState();
|
||||
state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
|
||||
const partialize = useWorkspaceLayoutStore.persist.getOptions().partialize;
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
expect(partialize?.(state)).toEqual({
|
||||
layoutByWorkspace: {},
|
||||
@@ -1096,11 +1112,9 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"67676767-6767-6767-6767-676767676767",
|
||||
);
|
||||
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
@@ -1114,7 +1128,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
@@ -1126,7 +1140,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
@@ -1169,7 +1183,7 @@ describe("workspace-layout-store actions", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1179,7 +1193,7 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
@@ -1200,14 +1214,14 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
workspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
hiddenAgentIdsByWorkspace: {
|
||||
[workspaceKey]: new Set<string>(["agent-1"]),
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
@@ -1217,13 +1231,13 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["parent-agent", "child-agent"],
|
||||
@@ -1234,7 +1248,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1243,7 +1257,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1258,7 +1272,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1267,7 +1281,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
@@ -1282,7 +1296,7 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
useWorkspaceLayoutStore
|
||||
workspaceLayoutStore
|
||||
.getState()
|
||||
.getWorkspaceTabs(workspaceKey)
|
||||
.map((tab) => tab.tabId),
|
||||
@@ -1291,7 +1305,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "child-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
@@ -1304,11 +1318,11 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_child-agent",
|
||||
@@ -1317,7 +1331,7 @@ describe("workspace-layout-store actions", () => {
|
||||
|
||||
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
const scriptTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "terminal",
|
||||
@@ -1335,8 +1349,8 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||
});
|
||||
@@ -1344,7 +1358,7 @@ describe("workspace-layout-store actions", () => {
|
||||
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
@@ -1355,46 +1369,44 @@ describe("workspace-layout-store actions", () => {
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("explicitly opening an agent tab clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
||||
});
|
||||
|
||||
it("pinning an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
expect(
|
||||
useWorkspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey],
|
||||
).toBeDefined();
|
||||
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
|
||||
|
||||
store.pinAgent(workspaceKey, "agent-1");
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("retargeting a tab to an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.hideAgent(workspaceKey, "agent-1");
|
||||
const tabId = store.openTabFocused(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
store.retargetTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
const state = workspaceLayoutStore.getState();
|
||||
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { selectSubagentsForParent } from "@/subagents";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
@@ -151,15 +152,17 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility {
|
||||
}
|
||||
|
||||
function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgentVisibility): void {
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: visibility.activeAgentIds,
|
||||
autoOpenAgentIds: visibility.autoOpenAgentIds,
|
||||
knownAgentIds: visibility.knownAgentIds,
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(
|
||||
workspaceKey,
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility: visibility,
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkspaceTabIds(workspaceKey: string): string[] {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pure-data entry point for callers that don't want React Native deps in
|
||||
// their dependency graph (e.g. workspace-agent-visibility.ts is plain JS
|
||||
// their dependency graph (e.g. workspace-tabs/agent-visibility.ts is plain
|
||||
// data derivation and its tests run without an RN environment).
|
||||
//
|
||||
// The full module entry `@/subagents` re-exports these too, alongside the
|
||||
|
||||
94
packages/app/src/tool-calls/presentation.test.ts
Normal file
94
packages/app/src/tool-calls/presentation.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
|
||||
|
||||
const fakeIcons = {
|
||||
brain: (() => null) as ToolCallPresentationIcon,
|
||||
eye: (() => null) as ToolCallPresentationIcon,
|
||||
wrench: (() => null) as ToolCallPresentationIcon,
|
||||
};
|
||||
|
||||
function fakeResolveIcon(
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
): ToolCallPresentationIcon {
|
||||
if (detail?.type === "plan") {
|
||||
return fakeIcons.brain;
|
||||
}
|
||||
if (detail?.type === "read") {
|
||||
return fakeIcons.eye;
|
||||
}
|
||||
if (toolName === "exec_command") {
|
||||
return fakeIcons.wrench;
|
||||
}
|
||||
return fakeIcons.wrench;
|
||||
}
|
||||
|
||||
describe("tool-call presentation", () => {
|
||||
it("builds badge, detail, icon, and file-open policy in one model", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "read_file",
|
||||
status: "completed",
|
||||
error: null,
|
||||
cwd: "/tmp/repo",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/repo/src/index.ts",
|
||||
content: "console.log('hi');",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation).toMatchObject({
|
||||
displayName: "Read",
|
||||
summary: "src/index.ts",
|
||||
icon: fakeIcons.eye,
|
||||
isLoadingDetails: false,
|
||||
hasDetails: true,
|
||||
canOpenDetails: true,
|
||||
openFilePath: "/tmp/repo/src/index.ts",
|
||||
isPlan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks running calls without meaningful detail as loading details", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "exec_command",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {},
|
||||
output: null,
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation).toMatchObject({
|
||||
displayName: "Exec Command",
|
||||
icon: fakeIcons.wrench,
|
||||
isLoadingDetails: true,
|
||||
hasDetails: false,
|
||||
canOpenDetails: true,
|
||||
openFilePath: null,
|
||||
isPlan: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plan calls out of the expandable badge path", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "ExitPlanMode",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: "1. Do the thing",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
|
||||
expect(presentation.isPlan).toBe(true);
|
||||
expect(presentation.icon).toBe(fakeIcons.brain);
|
||||
});
|
||||
});
|
||||
79
packages/app/src/tool-calls/presentation.ts
Normal file
79
packages/app/src/tool-calls/presentation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { ToolCallDisplayInput } from "@/utils/tool-call-display";
|
||||
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||
import { extractToolCallFilePath } from "@/utils/extract-tool-call-file-path";
|
||||
import {
|
||||
hasMeaningfulToolCallDetail,
|
||||
isPendingToolCallDetail,
|
||||
} from "@/utils/tool-call-detail-state";
|
||||
|
||||
type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
interface BuildToolCallPresentationInput {
|
||||
toolName: string;
|
||||
status: ToolCallStatus;
|
||||
error: unknown;
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
resolveIcon: ToolCallIconResolver;
|
||||
}
|
||||
|
||||
export interface ToolCallPresentation {
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
icon: ToolCallPresentationIcon;
|
||||
isLoadingDetails: boolean;
|
||||
hasDetails: boolean;
|
||||
canOpenDetails: boolean;
|
||||
openFilePath: string | null;
|
||||
isPlan: boolean;
|
||||
}
|
||||
|
||||
export type ToolCallIconResolver = (
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
) => ToolCallPresentationIcon;
|
||||
|
||||
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
|
||||
return status === "executing" ? "running" : status;
|
||||
}
|
||||
|
||||
function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
|
||||
return detail ?? { type: "unknown", input: null, output: null };
|
||||
}
|
||||
|
||||
export function buildToolCallPresentation(
|
||||
input: BuildToolCallPresentationInput,
|
||||
): ToolCallPresentation {
|
||||
const detailForDisplay = displayDetail(input.detail);
|
||||
const displayModel = buildToolCallDisplayModel({
|
||||
name: input.toolName,
|
||||
status: displayStatus(input.status),
|
||||
error: input.error ?? null,
|
||||
detail: detailForDisplay,
|
||||
metadata: input.metadata,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: input.detail,
|
||||
status: input.status,
|
||||
error: input.error,
|
||||
});
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
|
||||
|
||||
return {
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, input.detail),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
openFilePath: extractToolCallFilePath(input.detail),
|
||||
isPlan: input.detail?.type === "plan",
|
||||
};
|
||||
}
|
||||
133
packages/app/src/utils/navigate-to-agent.test.ts
Normal file
133
packages/app/src/utils/navigate-to-agent.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { routerMock } = vi.hoisted(() => ({
|
||||
routerMock: {
|
||||
dismissTo: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
useLocalSearchParams: () => ({}),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
function createWorkspace(): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "worktree",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createAgent(input: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
id: AGENT_ID,
|
||||
provider: "codex",
|
||||
status: "closed",
|
||||
createdAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Archived agent",
|
||||
cwd: "/repo/worktree",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
archivedAt: new Date("2026-05-09T00:00:00.000Z"),
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
routerMock.dismissTo.mockReset();
|
||||
routerMock.navigate.mockReset();
|
||||
routerMock.replace.mockReset();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, createWorkspace()]]));
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens archived agent details through the resolved workspace", () => {
|
||||
useSessionStore.getState().setAgentDetails(SERVER_ID, new Map([[AGENT_ID, createAgent()]]));
|
||||
|
||||
const route = navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true });
|
||||
|
||||
expect(route).toBe("/h/server-1/workspace/workspace-1");
|
||||
expect(routerMock.navigate).not.toHaveBeenCalled();
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-1");
|
||||
const key = `${SERVER_ID}:${WORKSPACE_ID}`;
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toEqual([
|
||||
expect.objectContaining({ target: { kind: "agent", agentId: AGENT_ID } }),
|
||||
]);
|
||||
expect(useWorkspaceLayoutStore.getState().pinnedAgentIdsByWorkspace[key]).toEqual(
|
||||
new Set([AGENT_ID]),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the workspace is unknown", () => {
|
||||
const route = navigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" });
|
||||
|
||||
expect(route).toBe("/h/server-1/agent/missing-agent");
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith("/h/server-1/agent/missing-agent");
|
||||
expect(routerMock.dismissTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
35
packages/app/src/utils/navigate-to-agent.ts
Normal file
35
packages/app/src/utils/navigate-to-agent.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface NavigateToAgentInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
currentPathname?: string | null;
|
||||
pin?: boolean;
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: session?.workspaces.values(),
|
||||
workspaceDirectory: agent?.cwd,
|
||||
});
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
router.navigate(route as Href);
|
||||
return route;
|
||||
}
|
||||
|
||||
return navigateToPreparedWorkspaceTab({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: input.agentId },
|
||||
currentPathname: input.currentPathname,
|
||||
pin: input.pin,
|
||||
});
|
||||
}
|
||||
@@ -1,41 +1,16 @@
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
|
||||
export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined;
|
||||
import {
|
||||
deriveAgentStateBucket,
|
||||
type AgentAttentionReason,
|
||||
type AgentStateBucketInput,
|
||||
} from "@server/shared/agent-state-bucket";
|
||||
|
||||
export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done";
|
||||
export type SidebarAttentionReason = AgentAttentionReason;
|
||||
|
||||
export function deriveSidebarStateBucket(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): SidebarStateBucket {
|
||||
if ((input.pendingPermissionCount ?? 0) > 0) {
|
||||
return "needs_input";
|
||||
}
|
||||
// Legacy fallback for snapshots persisted before permission state was decoupled
|
||||
// from unread attention.
|
||||
if (input.attentionReason === "permission") {
|
||||
return "needs_input";
|
||||
}
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
// Unread/attention-needed completed agents are active in sidebar logic.
|
||||
return "attention";
|
||||
}
|
||||
return "done";
|
||||
export function deriveSidebarStateBucket(input: AgentStateBucketInput): SidebarStateBucket {
|
||||
return deriveAgentStateBucket(input);
|
||||
}
|
||||
|
||||
export function isSidebarActiveAgent(input: {
|
||||
status: AgentLifecycleStatus;
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: SidebarAttentionReason;
|
||||
}): boolean {
|
||||
export function isSidebarActiveAgent(input: AgentStateBucketInput): boolean {
|
||||
return deriveSidebarStateBucket(input) !== "done";
|
||||
}
|
||||
|
||||
@@ -1,154 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
|
||||
const daemonClientMock = vi.hoisted(() => {
|
||||
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
|
||||
let nextConnectError: Error | null = null;
|
||||
let nextLastError: string | null = null;
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
|
||||
class MockDaemonClient {
|
||||
public lastError: string | null = nextLastError;
|
||||
private lastServerInfo = {
|
||||
status: "server_info" as const,
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host" as string | null,
|
||||
version: "0.0.0",
|
||||
};
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
readonly config: DaemonClientConfig,
|
||||
) {
|
||||
this.lastError = probe.nextLastError;
|
||||
}
|
||||
|
||||
constructor(config: { clientId?: string; url?: string; password?: string }) {
|
||||
createdConfigs.push(config);
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (nextConnectError) {
|
||||
throw nextConnectError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.lastServerInfo;
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
return { rttMs: 42 };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return;
|
||||
async connect(): Promise<void> {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockDaemonClient,
|
||||
createdConfigs,
|
||||
setNextConnectFailure: (error: Error, lastError: string | null) => {
|
||||
nextConnectError = error;
|
||||
nextLastError = lastError;
|
||||
getLastServerInfoMessage() {
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.probe.closedClients.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonProbe {
|
||||
createdClients: FakeDaemonClient[] = [];
|
||||
closedClients: FakeDaemonClient[] = [];
|
||||
clientIdsRequested = 0;
|
||||
nextConnectError: Error | null = null;
|
||||
nextLastError: string | null = null;
|
||||
|
||||
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
|
||||
getClientId: async () => {
|
||||
this.clientIdsRequested += 1;
|
||||
return "cid_shared_probe_test";
|
||||
},
|
||||
reset: () => {
|
||||
createdConfigs.length = 0;
|
||||
nextConnectError = null;
|
||||
nextLastError = null;
|
||||
resolveAppVersion: () => null,
|
||||
createLocalTransportFactory: () => null,
|
||||
buildLocalTransportUrl: ({ transportType, transportPath }) =>
|
||||
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
createClient: (config) => {
|
||||
const client = new FakeDaemonClient(this, config);
|
||||
this.createdClients.push(client);
|
||||
return client;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const clientIdMock = vi.hoisted(() => ({
|
||||
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
|
||||
}));
|
||||
failNextConnection(error: Error, lastError: string | null): void {
|
||||
this.nextConnectError = error;
|
||||
this.nextLastError = lastError;
|
||||
}
|
||||
|
||||
vi.mock("@server/client/daemon-client", () => ({
|
||||
DaemonClient: daemonClientMock.MockDaemonClient,
|
||||
}));
|
||||
|
||||
vi.mock("./client-id", () => ({
|
||||
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
|
||||
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
|
||||
buildLocalDaemonTransportUrl: vi.fn(
|
||||
({
|
||||
transportType,
|
||||
transportPath,
|
||||
}: {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
),
|
||||
}));
|
||||
createdConfigs(): DaemonClientConfig[] {
|
||||
return this.createdClients.map((client) => client.config);
|
||||
}
|
||||
}
|
||||
|
||||
describe("test-daemon-connection connectToDaemon", () => {
|
||||
let probe: FakeDaemonProbe;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonClientMock.reset();
|
||||
clientIdMock.getOrCreateClientId.mockClear();
|
||||
vi.stubGlobal("__DEV__", false);
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it("reuses the app clientId for direct connections", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const first = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const first = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await first.client.close();
|
||||
|
||||
const second = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const second = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await second.client.close();
|
||||
|
||||
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
|
||||
const [firstConfig, secondConfig] = probe.createdConfigs();
|
||||
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
||||
expect(probe.clientIdsRequested).toBe(2);
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
);
|
||||
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
|
||||
});
|
||||
|
||||
it("passes direct TCP connection passwords into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("uses relay TLS from the stored connection", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const tlsResult = await mod.connectToDaemon(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const tlsResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:wss:[::1]:443",
|
||||
type: "relay",
|
||||
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await tlsResult.client.close();
|
||||
|
||||
const plainResult = await mod.connectToDaemon(
|
||||
const plainResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await plainResult.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
});
|
||||
|
||||
it("surfaces auth rejection as an incorrect password", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(
|
||||
new Error("Transport closed (code 4001)"),
|
||||
"Transport closed (code 4001)",
|
||||
);
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Incorrect password",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic transport failures generic when a password was supplied", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(new Error("Transport error"), "Transport error");
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Transport error",
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
||||
}
|
||||
|
||||
interface LocalTransportUrlInput {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
||||
createClient(config: DaemonClientConfig): TClient;
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const clientId = await deps.getClientId();
|
||||
const localTransportFactory = deps.createLocalTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
@@ -87,7 +119,7 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return {
|
||||
...base,
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
url: deps.buildLocalTransportUrl({
|
||||
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
||||
transportPath: connection.path,
|
||||
}),
|
||||
@@ -120,10 +152,23 @@ export async function buildClientConfig(
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const client = new DaemonClient(config);
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe<TClient extends DaemonProbeClient>(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"createClient"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const client = deps.createClient(config);
|
||||
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
|
||||
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
|
||||
return connection.type === "relay" ? 10_000 : 6_000;
|
||||
}
|
||||
|
||||
export function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectToDaemon<TClient extends DaemonProbeClient>(
|
||||
connection: HostConnection,
|
||||
options: ProbeOptions | undefined,
|
||||
deps: DaemonConnectionDependencies<TClient>,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export async function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options));
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
@@ -265,6 +266,34 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
const agentVisibility = {
|
||||
activeAgentIds: new Set(["active-agent"]),
|
||||
autoOpenAgentIds: new Set(["root-agent"]),
|
||||
knownAgentIds: new Set(["active-agent", "archived-agent"]),
|
||||
};
|
||||
|
||||
expect(
|
||||
buildWorkspaceTabSnapshot({
|
||||
agentVisibility,
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
}),
|
||||
).toEqual({
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
|
||||
knownAgentIds: agentVisibility.knownAgentIds,
|
||||
knownTerminalIds: ["terminal-1", "script-terminal"],
|
||||
standaloneTerminalIds: ["terminal-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
it("returns true for identical sets", () => {
|
||||
const a = {
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
@@ -52,6 +53,26 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
return { activeAgentIds, autoOpenAgentIds, knownAgentIds };
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabSnapshot(input: {
|
||||
agentVisibility: WorkspaceAgentVisibility;
|
||||
agentsHydrated: boolean;
|
||||
terminalsHydrated: boolean;
|
||||
knownTerminalIds: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate: boolean;
|
||||
}): WorkspaceTabSnapshot {
|
||||
return {
|
||||
agentsHydrated: input.agentsHydrated,
|
||||
terminalsHydrated: input.terminalsHydrated,
|
||||
activeAgentIds: input.agentVisibility.activeAgentIds,
|
||||
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
|
||||
knownAgentIds: input.agentVisibility.knownAgentIds,
|
||||
knownTerminalIds: input.knownTerminalIds,
|
||||
standaloneTerminalIds: input.standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: input.hasActivePendingDraftCreate,
|
||||
};
|
||||
}
|
||||
|
||||
export function workspaceAgentVisibilityEqual(
|
||||
a: WorkspaceAgentVisibility,
|
||||
b: WorkspaceAgentVisibility,
|
||||
191
packages/app/src/workspace/workspace-archive.test.ts
Normal file
191
packages/app/src/workspace/workspace-archive.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
type WorkspaceArchiveTarget,
|
||||
} from "@/workspace/workspace-archive";
|
||||
|
||||
const SERVER_ID = "workspace-archive-test";
|
||||
|
||||
type ArchiveWorkspacePayload = Awaited<ReturnType<DaemonClient["archiveWorkspace"]>>;
|
||||
|
||||
function archivePayload(input: {
|
||||
workspaceId: string;
|
||||
error?: string | null;
|
||||
}): ArchiveWorkspacePayload {
|
||||
return {
|
||||
requestId: "request",
|
||||
workspaceId: input.workspaceId,
|
||||
archivedAt: null,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo/project",
|
||||
workspaceDirectory: "/repo/project/workspace-1",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "workspace-1",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget {
|
||||
const base = workspace();
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: base.id,
|
||||
workspaceDirectory: base.workspaceDirectory,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(
|
||||
archiveWorkspace: DaemonClient["archiveWorkspace"],
|
||||
): Pick<DaemonClient, "archiveWorkspace"> {
|
||||
return { archiveWorkspace };
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => {};
|
||||
let reject: (error: unknown) => void = () => {};
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function storedWorkspace(id: string): WorkspaceDescriptor | undefined {
|
||||
return useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get(id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" });
|
||||
clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" });
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
describe("archiveWorkspaceOptimistically", () => {
|
||||
it("hides the workspace and marks the archive pending while the daemon call runs", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const releaseArchive = deferred<ArchiveWorkspacePayload>();
|
||||
const client = createClient(vi.fn(async () => releaseArchive.promise));
|
||||
|
||||
const archive = archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
});
|
||||
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: archived.id,
|
||||
workspaceDirectory: archived.workspaceDirectory,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
releaseArchive.resolve(archivePayload({ workspaceId: archived.id }));
|
||||
await archive;
|
||||
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores the workspace and clears pending state when the daemon rejects the archive", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(
|
||||
vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
|
||||
expect(storedWorkspace(archived.id)).toEqual(archived);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: archived.id,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the after-hide hook after local state is hidden", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
|
||||
const afterHide = vi.fn(() => {
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
afterHide,
|
||||
});
|
||||
|
||||
expect(afterHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveWorkspacesOptimistically", () => {
|
||||
it("returns failures and restores only the workspaces whose archive failed", async () => {
|
||||
const first = workspace({ id: "workspace-1" });
|
||||
const second = workspace({
|
||||
id: "workspace-2",
|
||||
workspaceDirectory: "/repo/project/workspace-2",
|
||||
name: "workspace-2",
|
||||
});
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]);
|
||||
const client = createClient(
|
||||
vi.fn(async (workspaceId) =>
|
||||
archivePayload({
|
||||
workspaceId,
|
||||
error: workspaceId === second.id ? "failed" : null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const failures = await archiveWorkspacesOptimistically({
|
||||
client,
|
||||
workspaces: [
|
||||
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]?.workspaceId).toBe(second.id);
|
||||
expect(storedWorkspace(first.id)).toBeUndefined();
|
||||
expect(storedWorkspace(second.id)).toEqual(second);
|
||||
});
|
||||
});
|
||||
122
packages/app/src/workspace/workspace-archive.ts
Normal file
122
packages/app/src/workspace/workspace-archive.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceArchiveClient {
|
||||
archiveWorkspace: (workspaceId: string) => Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
interface OptimisticWorkspaceArchiveSnapshot {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceArchiveFailure {
|
||||
workspaceId: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFailure {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"workspaceId" in error &&
|
||||
typeof error.workspaceId === "string" &&
|
||||
"error" in error
|
||||
);
|
||||
}
|
||||
|
||||
function hideWorkspaceOptimistically(
|
||||
workspace: WorkspaceArchiveTarget,
|
||||
): OptimisticWorkspaceArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return { workspace: snapshot };
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: OptimisticWorkspaceArchiveSnapshot;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot.workspace) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveWorkspaceOrThrow(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const payload = await input.client.archiveWorkspace(input.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspace: WorkspaceArchiveTarget;
|
||||
afterHide?: () => void;
|
||||
}): Promise<void> {
|
||||
const snapshot = hideWorkspaceOptimistically(input.workspace);
|
||||
input.afterHide?.();
|
||||
|
||||
try {
|
||||
await archiveWorkspaceOrThrow({
|
||||
client: input.client,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: input.workspace.serverId,
|
||||
workspaceId: input.workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveWorkspacesOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspaces: WorkspaceArchiveTarget[];
|
||||
}): Promise<WorkspaceArchiveFailure[]> {
|
||||
const results = await Promise.allSettled(
|
||||
input.workspaces.map(async (workspace) => {
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client: input.client,
|
||||
workspace,
|
||||
});
|
||||
} catch (error) {
|
||||
throw { workspaceId: workspace.workspaceId, error } satisfies WorkspaceArchiveFailure;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flatMap((result) =>
|
||||
result.status === "rejected" && isWorkspaceArchiveFailure(result.reason) ? [result.reason] : [],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.71",
|
||||
"version": "0.1.75",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -24,13 +24,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.71",
|
||||
"@getpaseo/server": "0.1.75",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^3.0.1",
|
||||
|
||||
@@ -1,32 +1,67 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
spawnSync: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
import {
|
||||
type DaemonLaunchRuntime,
|
||||
type DetachedDaemonProcess,
|
||||
startLocalDaemonDetached,
|
||||
startLocalDaemonForeground,
|
||||
} from "./local-daemon.js";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: mocks.spawnSync,
|
||||
};
|
||||
});
|
||||
type RecordedDaemonLaunch =
|
||||
| {
|
||||
mode: "detached";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2];
|
||||
}
|
||||
| {
|
||||
mode: "foreground";
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2];
|
||||
};
|
||||
|
||||
vi.mock("@getpaseo/server", async () => {
|
||||
const actual = await vi.importActual<typeof import("@getpaseo/server")>("@getpaseo/server");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({ listen: "127.0.0.1:6767" }),
|
||||
resolvePaseoHome: (env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo",
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
};
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess {
|
||||
pid = 4242;
|
||||
unref = vi.fn();
|
||||
wasUnreferenced = false;
|
||||
|
||||
unref(): void {
|
||||
this.wasUnreferenced = true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonRuntime implements DaemonLaunchRuntime {
|
||||
readonly recordedLaunches: RecordedDaemonLaunch[] = [];
|
||||
readonly daemonProcess = new FakeDaemonProcess();
|
||||
foregroundStatus = 0;
|
||||
runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts";
|
||||
|
||||
resolveRunnerEntry(): string {
|
||||
return this.runnerEntry;
|
||||
}
|
||||
|
||||
resolveHome(env: NodeJS.ProcessEnv): string {
|
||||
return env.PASEO_HOME ?? "/tmp/paseo";
|
||||
}
|
||||
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2],
|
||||
): DetachedDaemonProcess {
|
||||
this.recordedLaunches.push({ mode: "detached", command, args, options });
|
||||
return this.daemonProcess;
|
||||
}
|
||||
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2],
|
||||
) {
|
||||
this.recordedLaunches.push({ mode: "foreground", command, args, options });
|
||||
return { status: this.foregroundStatus, error: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function expectSupervisorLaunch(argv: string[]): void {
|
||||
@@ -41,59 +76,59 @@ function expectSupervisorLaunch(argv: string[]): void {
|
||||
describe("local daemon launch supervision", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mocks.spawnSync.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
});
|
||||
|
||||
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false });
|
||||
const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime);
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(mocks.spawnSync).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-relay");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-relay");
|
||||
});
|
||||
|
||||
test("detached start spawns supervisor-entrypoint instead of server/index", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChildProcess();
|
||||
mocks.spawnProcess.mockReturnValue(child);
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonDetached } = await import("./local-daemon.js");
|
||||
const resultPromise = startLocalDaemonDetached({ home: "/tmp/paseo-test", mcp: false });
|
||||
const resultPromise = startLocalDaemonDetached(
|
||||
{ home: "/tmp/paseo-test", mcp: false },
|
||||
runtime,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" });
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
expect(mocks.spawnProcess).toHaveBeenCalledOnce();
|
||||
const [command, argv] = mocks.spawnProcess.mock.calls[0] as [string, string[]];
|
||||
expect(command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(argv);
|
||||
expect(argv).toContain("--no-mcp");
|
||||
expect(runtime.daemonProcess.wasUnreferenced).toBe(true);
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("detached");
|
||||
expect(launch?.command).toBe(process.execPath);
|
||||
expectSupervisorLaunch(launch?.args ?? []);
|
||||
expect(launch?.args).toContain("--no-mcp");
|
||||
});
|
||||
|
||||
test("relay TLS flag is passed to the supervised daemon", async () => {
|
||||
mocks.spawnSync.mockReturnValue({ status: 0, error: undefined });
|
||||
const runtime = new FakeDaemonRuntime();
|
||||
|
||||
const { startLocalDaemonForeground } = await import("./local-daemon.js");
|
||||
const status = startLocalDaemonForeground({
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
});
|
||||
const status = startLocalDaemonForeground(
|
||||
{
|
||||
home: "/tmp/paseo-test",
|
||||
relayUseTls: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(status).toBe(0);
|
||||
const [, argv, options] = mocks.spawnSync.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ env?: NodeJS.ProcessEnv },
|
||||
];
|
||||
expect(argv).toContain("--relay-use-tls");
|
||||
expect(options.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]);
|
||||
const launch = runtime.recordedLaunches[0];
|
||||
expect(launch?.mode).toBe("foreground");
|
||||
expect(launch?.args).toContain("--relay-use-tls");
|
||||
expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
@@ -68,6 +68,28 @@ interface ProcessExitDetails {
|
||||
|
||||
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
|
||||
|
||||
export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {}
|
||||
|
||||
export interface ForegroundDaemonProcessResult {
|
||||
status: number | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface DaemonLaunchRuntime {
|
||||
resolveRunnerEntry(): string;
|
||||
resolveHome(env: NodeJS.ProcessEnv): string;
|
||||
spawnDetached(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnProcess>[2],
|
||||
): DetachedDaemonProcess;
|
||||
spawnForeground(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Parameters<typeof spawnSync>[2],
|
||||
): ForegroundDaemonProcessResult;
|
||||
}
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
@@ -78,6 +100,13 @@ export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
|
||||
resolveRunnerEntry: resolveDaemonRunnerEntry,
|
||||
resolveHome: resolvePaseoHome,
|
||||
spawnDetached: spawnProcess,
|
||||
spawnForeground: spawnSync,
|
||||
};
|
||||
|
||||
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
|
||||
|
||||
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
|
||||
@@ -395,17 +424,18 @@ export function tailDaemonLog(home?: string, lines = 30): string | null {
|
||||
|
||||
export async function startLocalDaemonDetached(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): Promise<DetachedStartResult> {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv);
|
||||
const paseoHome = runtime.resolveHome(childEnv);
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
|
||||
const child = spawnProcess(
|
||||
const child = runtime.spawnDetached(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
@@ -461,14 +491,17 @@ export async function startLocalDaemonDetached(
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
|
||||
export function startLocalDaemonForeground(
|
||||
options: DaemonStartOptions,
|
||||
runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime,
|
||||
): number {
|
||||
if (options.listen && options.port) {
|
||||
throw new Error("Cannot use --listen and --port together");
|
||||
}
|
||||
|
||||
const daemonRunnerEntry = resolveDaemonRunnerEntry();
|
||||
const daemonRunnerEntry = runtime.resolveRunnerEntry();
|
||||
const childEnv = buildChildEnv(options);
|
||||
const result = spawnSync(
|
||||
const result = runtime.spawnForeground(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
|
||||
{
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCreateWorktreeInput,
|
||||
toDaemonCreateInput,
|
||||
type WorktreeCreateOptions,
|
||||
} from "./create.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
const REPO = "/tmp/repo";
|
||||
|
||||
function build(options: WorktreeCreateOptions): unknown {
|
||||
try {
|
||||
return buildCreateWorktreeInput(options, REPO);
|
||||
return buildCreateWorktreeRequest(options, REPO);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildCreateWorktreeInput", () => {
|
||||
describe("buildCreateWorktreeRequest", () => {
|
||||
it("requires --mode", () => {
|
||||
expect(build({})).toMatchObject({ code: "MISSING_MODE" });
|
||||
});
|
||||
@@ -28,17 +24,20 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "branch-off" })).toMatchObject({ code: "MISSING_NEW_BRANCH" });
|
||||
});
|
||||
|
||||
it("branch-off parses with new branch only", () => {
|
||||
it("branch-off builds a daemon request with a new branch", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
});
|
||||
});
|
||||
|
||||
it("branch-off parses with base ref", () => {
|
||||
it("branch-off includes the base ref when provided", () => {
|
||||
expect(build({ mode: "branch-off", newBranch: "feature-x", base: "main" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +45,11 @@ describe("buildCreateWorktreeInput", () => {
|
||||
expect(build({ mode: "checkout-branch" })).toMatchObject({ code: "MISSING_BRANCH" });
|
||||
});
|
||||
|
||||
it("checkout-branch parses with branch", () => {
|
||||
it("checkout-branch builds a checkout request for the branch", () => {
|
||||
expect(build({ mode: "checkout-branch", branch: "feat/x" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-branch", branch: "feat/x" },
|
||||
action: "checkout",
|
||||
refName: "feat/x",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,62 +69,8 @@ describe("buildCreateWorktreeInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checkout-pr parses positive integers", () => {
|
||||
it("checkout-pr builds a checkout request for the pull request", () => {
|
||||
expect(build({ mode: "checkout-pr", prNumber: "42" })).toEqual({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-pr", prNumber: 42 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDaemonCreateInput", () => {
|
||||
it("maps branch-off without base", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps branch-off with base ref", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "branch-off", newBranch: "feature-x", base: "main" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
worktreeSlug: "feature-x",
|
||||
action: "branch-off",
|
||||
refName: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps checkout-branch to action=checkout + refName", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-branch", branch: "feat/x" },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
action: "checkout",
|
||||
refName: "feat/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps checkout-pr to action=checkout + githubPrNumber", () => {
|
||||
expect(
|
||||
toDaemonCreateInput({
|
||||
cwd: REPO,
|
||||
target: { mode: "checkout-pr", prNumber: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: REPO,
|
||||
action: "checkout",
|
||||
githubPrNumber: 42,
|
||||
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
104
packages/cli/src/commands/worktree/create-input.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DaemonClient } from "@getpaseo/server";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
|
||||
export interface WorktreeCreateOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
mode?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
}
|
||||
|
||||
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
|
||||
|
||||
type CreatePaseoWorktreeRequest = Parameters<DaemonClient["createPaseoWorktree"]>[0];
|
||||
|
||||
export function buildCreateWorktreeRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
const mode = options.mode;
|
||||
if (!mode) {
|
||||
throw cmdError(
|
||||
"MISSING_MODE",
|
||||
"--mode is required",
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "branch-off":
|
||||
return buildBranchOffRequest(options, cwd);
|
||||
case "checkout-branch":
|
||||
return buildCheckoutBranchRequest(options, cwd);
|
||||
case "checkout-pr":
|
||||
return buildCheckoutPrRequest(options, cwd);
|
||||
default:
|
||||
throw cmdError(
|
||||
"INVALID_MODE",
|
||||
`Invalid --mode: ${mode}`,
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBranchOffRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (!options.newBranch) {
|
||||
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
worktreeSlug: options.newBranch,
|
||||
action: "branch-off",
|
||||
...(options.base ? { refName: options.base } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckoutBranchRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (!options.branch) {
|
||||
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
action: "checkout",
|
||||
refName: options.branch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckoutPrRequest(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): CreatePaseoWorktreeRequest {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
|
||||
const prNumber = Number(options.prNumber);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
throw cmdError(
|
||||
"INVALID_PR_NUMBER",
|
||||
`Invalid --pr-number: ${options.prNumber}`,
|
||||
"Expected a positive integer",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd,
|
||||
action: "checkout",
|
||||
githubPrNumber: prNumber,
|
||||
};
|
||||
}
|
||||
|
||||
function cmdError(code: string, message: string, details?: string): CommandError {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
@@ -2,12 +2,8 @@ import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/server";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandError,
|
||||
CommandOptions,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js";
|
||||
|
||||
export interface WorktreeCreateResult {
|
||||
name: string;
|
||||
@@ -24,110 +20,6 @@ export const createSchema: OutputSchema<WorktreeCreateResult> = {
|
||||
],
|
||||
};
|
||||
|
||||
export interface WorktreeCreateOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
mode?: string;
|
||||
newBranch?: string;
|
||||
base?: string;
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
}
|
||||
|
||||
export type WorktreeCreateTarget =
|
||||
| { mode: "branch-off"; newBranch: string; base?: string }
|
||||
| { mode: "checkout-branch"; branch: string }
|
||||
| { mode: "checkout-pr"; prNumber: number };
|
||||
|
||||
export interface ParsedWorktreeCreateInput {
|
||||
cwd: string;
|
||||
target: WorktreeCreateTarget;
|
||||
}
|
||||
|
||||
const VALID_MODES = ["branch-off", "checkout-branch", "checkout-pr"] as const;
|
||||
|
||||
export function buildCreateWorktreeInput(
|
||||
options: WorktreeCreateOptions,
|
||||
cwd: string,
|
||||
): ParsedWorktreeCreateInput {
|
||||
const mode = options.mode;
|
||||
if (!mode) {
|
||||
throw cmdError(
|
||||
"MISSING_MODE",
|
||||
"--mode is required",
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "branch-off": {
|
||||
if (!options.newBranch) {
|
||||
throw cmdError("MISSING_NEW_BRANCH", "--new-branch is required for --mode branch-off");
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
target: {
|
||||
mode: "branch-off",
|
||||
newBranch: options.newBranch,
|
||||
...(options.base ? { base: options.base } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
if (!options.branch) {
|
||||
throw cmdError("MISSING_BRANCH", "--branch is required for --mode checkout-branch");
|
||||
}
|
||||
return { cwd, target: { mode: "checkout-branch", branch: options.branch } };
|
||||
}
|
||||
case "checkout-pr": {
|
||||
if (options.prNumber === undefined || options.prNumber === "") {
|
||||
throw cmdError("MISSING_PR_NUMBER", "--pr-number is required for --mode checkout-pr");
|
||||
}
|
||||
const parsed = Number(options.prNumber);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw cmdError(
|
||||
"INVALID_PR_NUMBER",
|
||||
`Invalid --pr-number: ${options.prNumber}`,
|
||||
"Expected a positive integer",
|
||||
);
|
||||
}
|
||||
return { cwd, target: { mode: "checkout-pr", prNumber: parsed } };
|
||||
}
|
||||
default:
|
||||
throw cmdError(
|
||||
"INVALID_MODE",
|
||||
`Invalid --mode: ${mode}`,
|
||||
`Expected one of: ${VALID_MODES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function toDaemonCreateInput(parsed: ParsedWorktreeCreateInput) {
|
||||
switch (parsed.target.mode) {
|
||||
case "branch-off":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
worktreeSlug: parsed.target.newBranch,
|
||||
action: "branch-off" as const,
|
||||
...(parsed.target.base ? { refName: parsed.target.base } : {}),
|
||||
};
|
||||
case "checkout-branch":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
action: "checkout" as const,
|
||||
refName: parsed.target.branch,
|
||||
};
|
||||
case "checkout-pr":
|
||||
return {
|
||||
cwd: parsed.cwd,
|
||||
action: "checkout" as const,
|
||||
githubPrNumber: parsed.target.prNumber,
|
||||
};
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
function cmdError(code: string, message: string, details?: string): CommandError {
|
||||
return details ? { code, message, details } : { code, message };
|
||||
}
|
||||
@@ -137,7 +29,7 @@ export async function runCreateCommand(
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorktreeCreateResult>> {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const parsed = buildCreateWorktreeInput(options, cwd);
|
||||
const request = buildCreateWorktreeRequest(options, cwd);
|
||||
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client: DaemonClient;
|
||||
@@ -153,7 +45,7 @@ export async function runCreateCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.createPaseoWorktree(toDaemonCreateInput(parsed));
|
||||
const response = await client.createPaseoWorktree(request);
|
||||
|
||||
const workspace = response.workspace;
|
||||
if (!workspace || response.error) {
|
||||
|
||||
@@ -8,6 +8,26 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForLoopInList(
|
||||
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
|
||||
id: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
const listedJson = JSON.parse(listed.stdout);
|
||||
assert(Array.isArray(listedJson), listed.stdout);
|
||||
if (listedJson.some((item: { id: string }) => item.id === id)) {
|
||||
return listedJson;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
return JSON.parse(listed.stdout);
|
||||
}
|
||||
|
||||
console.log("=== Loop And Schedule Command Tests ===\n");
|
||||
|
||||
const ctx = await createE2ETestContext({ timeout: 30000 });
|
||||
@@ -143,13 +163,10 @@ try {
|
||||
const runJson = JSON.parse(run.stdout);
|
||||
assert.strictEqual(runJson.name, "smoke-loop");
|
||||
|
||||
const listed = await ctx.paseo(["loop", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
const listedJson = JSON.parse(listed.stdout);
|
||||
assert(Array.isArray(listedJson), listed.stdout);
|
||||
const listedJson = await waitForLoopInList(ctx, runJson.id);
|
||||
assert(
|
||||
listedJson.some((item: { id: string }) => item.id === runJson.id),
|
||||
listed.stdout,
|
||||
JSON.stringify(listedJson),
|
||||
);
|
||||
|
||||
async function pollStatus(attempt: number): Promise<string> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user