mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
171 Commits
fix-window
...
v0.1.75
| 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 | ||
|
|
314322d43d | ||
|
|
cce70d3080 | ||
|
|
43f136deba | ||
|
|
c725352888 | ||
|
|
d1bda25a0b | ||
|
|
d241c24776 | ||
|
|
ca503c1560 | ||
|
|
c34fb26359 | ||
|
|
030e163fb8 | ||
|
|
8b09846f98 | ||
|
|
e3ef88c297 | ||
|
|
3eda7dd15b | ||
|
|
34df55a10a | ||
|
|
0229ef94f1 | ||
|
|
5b884aee0c | ||
|
|
8be2692b8a | ||
|
|
103601705d | ||
|
|
68ceac492c | ||
|
|
ed07452c80 | ||
|
|
c097678cdf | ||
|
|
0364e8dc5e | ||
|
|
650d3148e9 | ||
|
|
511580a47e | ||
|
|
0be9764194 | ||
|
|
c40e1f03db | ||
|
|
3a5d999006 | ||
|
|
832a2031bb | ||
|
|
1042270298 | ||
|
|
436bab0c8a | ||
|
|
4792cba794 | ||
|
|
39099a54b8 | ||
|
|
c9d842bd33 | ||
|
|
ee4d72c706 | ||
|
|
53e477bc00 | ||
|
|
58fc2bb434 | ||
|
|
6e75c72492 | ||
|
|
3ed5567c8a | ||
|
|
ad08868778 | ||
|
|
5b6e974248 | ||
|
|
04b04fe5ee | ||
|
|
9d5a47b1c3 | ||
|
|
5c90449707 | ||
|
|
e490bf1dab | ||
|
|
b131876e69 | ||
|
|
b7d2e6d3ec | ||
|
|
09ed46b997 | ||
|
|
a4d365c8f2 | ||
|
|
13538dd710 | ||
|
|
e60350be08 | ||
|
|
b0c36f2bab | ||
|
|
d708f099be | ||
|
|
cb2fe91998 | ||
|
|
d7820b7a84 | ||
|
|
fb0fbb79f6 | ||
|
|
c97c000d9c | ||
|
|
c4e4a28bc0 | ||
|
|
39e461b872 | ||
|
|
734e15e5a3 | ||
|
|
90bf6571d6 | ||
|
|
90fe71bd54 | ||
|
|
89a500cd3e | ||
|
|
a1ac402154 | ||
|
|
f7eac82593 | ||
|
|
4a9c2450b7 | ||
|
|
27f33be0e7 | ||
|
|
4c11f7c40b | ||
|
|
50405c3b6a | ||
|
|
8406559150 | ||
|
|
f92a296ce0 | ||
|
|
6786024333 | ||
|
|
56855dd6fd | ||
|
|
00a5b27586 | ||
|
|
73ed98c623 | ||
|
|
927309e867 | ||
|
|
2d2ee02ce3 | ||
|
|
4d4fbf7257 | ||
|
|
67c93dba49 | ||
|
|
2d9c7747fb | ||
|
|
4fa1db8d06 | ||
|
|
92be6c0cba | ||
|
|
4f0b264886 | ||
|
|
bad304c2d3 | ||
|
|
0785ee31f0 |
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
|
||||
87
.github/workflows/ci.yml
vendored
87
.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
|
||||
@@ -66,7 +77,12 @@ jobs:
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -81,7 +97,10 @@ 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
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -95,50 +114,6 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
server-tests-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.probe.test.ts
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.percent-escape.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/tree-kill.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/terminal/worker-terminal-manager.test.ts
|
||||
src/server/agent/provider-registry.test.ts
|
||||
src/server/agent/provider-launch-config.test.ts
|
||||
src/server/agent/provider-snapshot-manager.test.ts
|
||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||
src/server/agent/providers/provider-windows-launch.test.ts
|
||||
src/server/agent/providers/provider-availability.test.ts
|
||||
src/server/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
|
||||
desktop-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -154,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
|
||||
@@ -179,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
|
||||
@@ -201,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
|
||||
@@ -216,7 +191,7 @@ jobs:
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
@@ -244,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
|
||||
@@ -268,10 +243,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
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
|
||||
|
||||
45
.github/workflows/fix-nix-hash.yml
vendored
45
.github/workflows/fix-nix-hash.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
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: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
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
|
||||
59
.github/workflows/nix-build.yml
vendored
59
.github/workflows/nix-build.yml
vendored
@@ -1,59 +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/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
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
|
||||
52
.github/workflows/server-ci.yml
vendored
52
.github/workflows/server-ci.yml
vendored
@@ -1,52 +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: 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 }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -66,6 +66,7 @@ CLAUDE.local.md
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
**/.tanstack/
|
||||
|
||||
# Local agent/tooling artifacts (do not commit)
|
||||
PLAN.md
|
||||
@@ -85,3 +86,4 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
137
CHANGELOG.md
137
CHANGELOG.md
@@ -1,5 +1,142 @@
|
||||
# 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** 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
|
||||
|
||||
- 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
|
||||
|
||||
- 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 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
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Claude agents now require `claude` on your PATH.** Install Claude Code globally (`npm install -g @anthropic-ai/claude-code`) before running a Claude agent — Paseo no longer ships a bundled fallback binary. Same posture as Codex and OpenCode, and shrinks the desktop install by ~210 MB per platform.
|
||||
|
||||
### Added
|
||||
|
||||
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
|
||||
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
|
||||
- Claude's Sonnet 4.6 1M context model is now selectable in the model picker.
|
||||
- Detect GitHub issue and PR URLs pasted into the composer search.
|
||||
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
|
||||
- `paseo schedule update` to edit a schedule in place without recreating it.
|
||||
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
|
||||
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
### Improved
|
||||
|
||||
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
|
||||
- Self-hosted relays can opt into `wss://` for TLS connections.
|
||||
- Workspace open targets only show options reachable from the current daemon.
|
||||
- Combobox search matches model descriptions, not just names.
|
||||
- Codex image attachments render inline as path markdown.
|
||||
- Subagent task notifications no longer clutter the parent agent's timeline.
|
||||
- Voice mode: quieter thinking tone and small UI polish.
|
||||
- Settings sidebar order: Projects now appears after General.
|
||||
- Electron upgraded to 41.2.0 for the desktop app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Claude agent: daemon no longer crashes mid-turn** when the underlying SDK fires a stray control message after the connection has been torn down.
|
||||
- **Windows:** Terminals start reliably and shut down cleanly without leaving stuck processes behind.
|
||||
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
|
||||
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
|
||||
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
|
||||
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
|
||||
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
|
||||
- Daemon shutdown terminates all child processes cleanly using tree-kill.
|
||||
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
|
||||
- OpenCode now forwards provider retry errors instead of silently swallowing them.
|
||||
- Codex import no longer reverts to the wrong default mode.
|
||||
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
|
||||
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
|
||||
- Workspace navigation regression on web fixed.
|
||||
- Duplicate workspace shell navigation eliminated.
|
||||
- The 'Update installed' callout no longer flashes incorrectly.
|
||||
- Browser pane reload focus and devtools handling.
|
||||
- MCP terminal capture now includes scrollback.
|
||||
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
|
||||
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
|
||||
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
|
||||
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
|
||||
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
|
||||
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
|
||||
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
56
CLAUDE.md
56
CLAUDE.md
@@ -15,19 +15,32 @@ This is an npm workspace monorepo:
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Documentation
|
||||
## Docs
|
||||
|
||||
| Doc | What's in it |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/design.md](docs/design.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -62,12 +75,19 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||
- `npm run lint -- packages/app/src/components/message.tsx`
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||
|
||||
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||
|
||||
## Platform gating
|
||||
|
||||
|
||||
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.
|
||||
|
||||
21
SECURITY.md
21
SECURITY.md
@@ -19,18 +19,19 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
### How it works
|
||||
|
||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
### Why the relay can't attack you
|
||||
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
@@ -41,11 +42,11 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
|
||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
@@ -55,7 +56,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const port = target!.type === "tcp" ? target!.port : null;
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54", // see gotcha #1
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -78,7 +78,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -116,7 +116,7 @@ Always pass `appVersion`:
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
107
docs/agent-lifecycle.md
Normal file
107
docs/agent-lifecycle.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Agent lifecycle
|
||||
|
||||
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
|
||||
|
||||
## States
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
1. Snapshot the current session into the registry
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
| Concept | Scope | Triggers |
|
||||
| -------------------------- | ---------- | -------------------------- |
|
||||
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
|
||||
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
|
||||
|
||||
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
|
||||
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
|
||||
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
|
||||
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
|
||||
|
||||
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
@@ -53,6 +53,8 @@ Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -51,39 +51,51 @@ The heart of Paseo. A Node.js process that:
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
||||
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
||||
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
|
||||
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands:
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree ls/archive`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- `paseo speech …`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
@@ -91,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
|
||||
@@ -113,30 +125,48 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
Client → Server: WSHelloMessage {
|
||||
type: "hello",
|
||||
clientId,
|
||||
clientType: "mobile" | "browser" | "cli" | "mcp",
|
||||
protocolVersion,
|
||||
appVersion?,
|
||||
capabilities?: { voice?, pushNotifications?, ... },
|
||||
}
|
||||
Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
|
||||
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
|
||||
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
|
||||
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
|
||||
- Terminal subscribe/input/capture commands
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
**Binary multiplexing:**
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)`
|
||||
- 1-byte slot: terminal slot id
|
||||
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
@@ -155,26 +185,44 @@ Example: adding a new enum value
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
initializing → idle ⇄ running
|
||||
↓ ↓ ↓
|
||||
error
|
||||
↓
|
||||
closed
|
||||
```
|
||||
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- `initializing` — provider session is being created
|
||||
- `idle` — has a live session, awaiting the next prompt
|
||||
- `running` — provider is currently producing a turn
|
||||
- `error` — last attempt failed; session is still attached
|
||||
- `closed` — terminal state, no live session
|
||||
|
||||
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
|
||||
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| -------- | ------------------- | -------------------------------------------------- |
|
||||
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode CLI | Provider-managed |
|
||||
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode server / CLI | Provider-managed |
|
||||
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
|
||||
| Generic ACP | ACP wrapper | Provider-managed |
|
||||
| Pi-direct | Direct Anthropic API call | Stateless |
|
||||
| Mock load test | In-process fake | In-memory |
|
||||
|
||||
All providers:
|
||||
|
||||
@@ -195,12 +243,21 @@ All providers:
|
||||
|
||||
## Storage
|
||||
|
||||
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
└── daemon.log # Daemon trace logs
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
@@ -1,188 +1,98 @@
|
||||
# Coding Standards
|
||||
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
|
||||
|
||||
For testing rules, see [testing.md](testing.md).
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — justify every abstraction with specific benefits
|
||||
- **Fully typed TypeScript** — no `any`, no untyped boundaries
|
||||
- **YAGNI** — build features and abstractions only when needed
|
||||
- **Functional and declarative** over object-oriented
|
||||
- **`interface`** over `type` when possible
|
||||
- **`function` declarations** over arrow function assignments
|
||||
- **Single-purpose functions** — one function, one job
|
||||
- **Design for edge cases through types** rather than explicit handling
|
||||
- **Don't catch errors** unless there's a strong reason to
|
||||
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
|
||||
- **No "while I'm at it" improvements** — stay focused on the task
|
||||
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
|
||||
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
|
||||
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
|
||||
- **Functional and declarative** over object-oriented.
|
||||
- **`function` declarations** over arrow function assignments.
|
||||
- **`interface`** over `type` when both work.
|
||||
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
|
||||
|
||||
## Type hygiene
|
||||
## Comments and noise
|
||||
|
||||
### Infer from schemas
|
||||
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
|
||||
- No tutorial comments explaining language features (`// Use destructuring to...`).
|
||||
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
|
||||
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
|
||||
- No commented-out code. Git remembers.
|
||||
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
|
||||
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
## Confidence: commit to a shape
|
||||
|
||||
```typescript
|
||||
// Bad: duplicate type that can drift
|
||||
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
|
||||
type RPCArgs = { procedure: string; args: Record<string, unknown> };
|
||||
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
|
||||
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
|
||||
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
|
||||
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
|
||||
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
|
||||
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
## Types
|
||||
|
||||
### Named types over inline
|
||||
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
|
||||
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
|
||||
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
|
||||
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
|
||||
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
|
||||
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
|
||||
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
|
||||
|
||||
No complex inline types in public function signatures.
|
||||
## Errors
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
|
||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput {
|
||||
userId: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
}
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
## Density
|
||||
|
||||
### Object parameters
|
||||
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
|
||||
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
|
||||
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
|
||||
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
|
||||
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
|
||||
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
## Structure and modules
|
||||
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
|
||||
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
|
||||
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
|
||||
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
|
||||
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
|
||||
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
|
||||
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
|
||||
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
|
||||
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput {
|
||||
provider: string;
|
||||
toolName: string;
|
||||
payload: unknown;
|
||||
}
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
## Refactoring is a bolt-on test
|
||||
|
||||
### One canonical type per concept
|
||||
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
|
||||
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
|
||||
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
|
||||
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
|
||||
|
||||
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
|
||||
## React
|
||||
|
||||
```typescript
|
||||
// Bad: duplicated fields across layers
|
||||
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
|
||||
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
|
||||
- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
|
||||
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
|
||||
- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
|
||||
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
// Good: canonical type + wrappers
|
||||
type ToolCall = { toolName: string; args: Record<string, unknown> };
|
||||
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
|
||||
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
|
||||
```
|
||||
## Naming
|
||||
|
||||
## Make impossible states impossible
|
||||
|
||||
Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState {
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "success"; data: Data };
|
||||
```
|
||||
|
||||
## Optionality is a design decision
|
||||
|
||||
Don't mark fields optional to avoid migrations. Decide deliberately:
|
||||
|
||||
1. Is optionality actually needed?
|
||||
2. If there are distinct valid states → discriminated union
|
||||
3. If value can be intentionally empty → explicit `null`
|
||||
4. Keep optionality at real boundaries (external input), then resolve it
|
||||
|
||||
## Validate at boundaries, trust internally
|
||||
|
||||
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
|
||||
|
||||
```typescript
|
||||
// Bad: optional chaining because shape is unclear
|
||||
const value = response?.data?.items?.[0]?.name;
|
||||
|
||||
// Good: validate at boundary, trust the types
|
||||
const parsed = responseSchema.parse(rawResponse);
|
||||
const value = parsed.data.items[0].name;
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
|
||||
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
|
||||
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
|
||||
|
||||
```typescript
|
||||
class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly operation: string,
|
||||
public readonly waitedMs: number,
|
||||
) {
|
||||
super(`${operation} timed out after ${waitedMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep logic density low
|
||||
|
||||
Avoid packing branching, lookup, and transformation into single dense expressions.
|
||||
|
||||
```typescript
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(
|
||||
account,
|
||||
rates.find((r) => r.region === account.region),
|
||||
);
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
if (!rate) throw new MissingRateError(account.region);
|
||||
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
|
||||
```
|
||||
|
||||
## Centralize policy
|
||||
|
||||
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
|
||||
|
||||
## React: keep components dumb
|
||||
|
||||
- Components render state and dispatch events — they don't compute transitions
|
||||
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
|
||||
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
|
||||
- Never mirror a source of truth into local state; derive from it
|
||||
- Test state logic as pure functions without rendering
|
||||
|
||||
## File organization
|
||||
|
||||
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
|
||||
- Name files after the main export (`create-toolcall.ts`)
|
||||
- Use `index.ts` as an entrypoint, not a dumping ground
|
||||
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
|
||||
|
||||
## Refactoring contract
|
||||
|
||||
Refactoring is structure work, not feature work.
|
||||
|
||||
- Preserve behavior by default, especially user-facing behavior
|
||||
- Do not remove features to simplify code without explicit approval
|
||||
- Have a verification strategy before you start
|
||||
- Fully migrate callers and remove old paths in the same refactor
|
||||
- No fallback behavior by default — prefer explicit error over silent degradation
|
||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
|
||||
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
|
||||
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
|
||||
- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`).
|
||||
- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_.
|
||||
|
||||
@@ -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
|
||||
@@ -83,6 +107,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -136,6 +161,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
@@ -344,7 +370,7 @@ Required fields for ACP providers:
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
@@ -550,6 +576,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -564,6 +591,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data Model
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
@@ -11,8 +11,12 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── config.json # Daemon configuration
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {project-dir}/
|
||||
│ └── {sanitized-cwd}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
@@ -26,6 +30,8 @@ $PASEO_HOME/
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
@@ -34,28 +40,29 @@ $PASEO_HOME/
|
||||
|
||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
@@ -114,7 +121,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string?` |
|
||||
| `value` | `string \| null` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
@@ -130,10 +137,11 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
@@ -143,16 +151,14 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
}
|
||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
||||
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||
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,
|
||||
@@ -170,7 +176,7 @@ All fields are optional with sensible defaults.
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -255,7 +261,7 @@ Single file containing all rooms and messages.
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records.
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
@@ -265,10 +271,12 @@ Single file containing an array of all loop records.
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
@@ -344,15 +352,15 @@ Single file containing an array of all loop records.
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------------------- | ------------------------------ |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | ---------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
---
|
||||
|
||||
@@ -362,16 +370,16 @@ Array of project records.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ----------------------- |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
|
||||
---
|
||||
|
||||
@@ -385,7 +393,20 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
| Path | Format | Notes |
|
||||
| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# Design system
|
||||
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Character
|
||||
|
||||
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
|
||||
|
||||
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
|
||||
|
||||
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component reuse
|
||||
|
||||
A semantic element used in three or more places is a primitive. One of a kind is a screen.
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hierarchy
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Buttons
|
||||
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
|
||||
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
|
||||
|
||||
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
|
||||
|
||||
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Borders
|
||||
|
||||
Borders group, separate, or rarely emphasize.
|
||||
|
||||
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
|
||||
|
||||
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
|
||||
|
||||
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
|
||||
|
||||
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
|
||||
|
||||
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pickers
|
||||
|
||||
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
|
||||
|
||||
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
|
||||
|
||||
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
## 8. Responsiveness
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
## 9. Copy and voice
|
||||
|
||||
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
||||
|
||||
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
|
||||
|
||||
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
|
||||
|
||||
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
|
||||
|
||||
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
|
||||
|
||||
Terminology:
|
||||
|
||||
- Workspace, never "checkout".
|
||||
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
|
||||
- Project, not "repo" or "repository".
|
||||
- Provider, not "model provider".
|
||||
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
|
||||
|
||||
---
|
||||
|
||||
## 10. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
||||
|
||||
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
|
||||
|
||||
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
||||
|
||||
---
|
||||
|
||||
## 11. List rows
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Status pills and badges
|
||||
|
||||
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Forbidden
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
---
|
||||
|
||||
## 14. Canonical surfaces by pattern
|
||||
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
|
||||
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
268
docs/design.md
268
docs/design.md
@@ -1,73 +1,247 @@
|
||||
# Designing Features
|
||||
# Design
|
||||
|
||||
How to think through a feature before writing code.
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
|
||||
## Start from the user
|
||||
---
|
||||
|
||||
Even for backend work, start from the user's perspective:
|
||||
## 1. Character
|
||||
|
||||
- What problem does this solve?
|
||||
- What triggers it? User action, schedule, event?
|
||||
- What does success look like from the user's perspective?
|
||||
- What data does it need? Where does that data come from?
|
||||
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
|
||||
|
||||
## Map existing code
|
||||
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
|
||||
|
||||
Before designing anything new, understand what exists:
|
||||
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
|
||||
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [architecture.md](./architecture.md))
|
||||
- What types and data shapes are already defined?
|
||||
---
|
||||
|
||||
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
|
||||
## 2. Component reuse
|
||||
|
||||
## Define verification before implementation
|
||||
A semantic element used in three or more places is a primitive. One of a kind is a screen.
|
||||
|
||||
Before designing the solution, define how you'll know it works:
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
## Design the shape
|
||||
---
|
||||
|
||||
### Data
|
||||
## 3. Hierarchy
|
||||
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
|
||||
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
### Layers
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
### Interactions
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
### Refactoring
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
## Create a concrete plan
|
||||
---
|
||||
|
||||
Once the design is clear:
|
||||
## 4. Buttons
|
||||
|
||||
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
|
||||
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
|
||||
3. **What to refactor** before adding new code
|
||||
4. **How to verify** each step
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
|
||||
## Principles
|
||||
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
|
||||
|
||||
- **Fit, don't force** — new code should fit existing patterns, or refactor first
|
||||
- **Simple** — the best design is the simplest one that works
|
||||
- **Verify early** — define how to test before designing the implementation
|
||||
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
|
||||
|
||||
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Borders
|
||||
|
||||
Borders group, separate, or rarely emphasize.
|
||||
|
||||
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
|
||||
|
||||
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
|
||||
|
||||
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
|
||||
|
||||
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
|
||||
|
||||
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pickers
|
||||
|
||||
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
|
||||
|
||||
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
|
||||
|
||||
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
## 8. Responsiveness
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
## 9. Copy and voice
|
||||
|
||||
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
||||
|
||||
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
|
||||
|
||||
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
|
||||
|
||||
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
|
||||
|
||||
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
|
||||
|
||||
Terminology:
|
||||
|
||||
- Workspace, never "checkout".
|
||||
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
|
||||
- Project, not "repo" or "repository".
|
||||
- Provider, not "model provider".
|
||||
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
|
||||
|
||||
---
|
||||
|
||||
## 10. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
||||
|
||||
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
|
||||
|
||||
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
||||
|
||||
---
|
||||
|
||||
## 11. List rows
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Status pills and badges
|
||||
|
||||
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Forbidden
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
---
|
||||
|
||||
## 14. Canonical surfaces by pattern
|
||||
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
|
||||
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
|
||||
@@ -11,54 +11,42 @@
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
|
||||
### Running alongside the main checkout
|
||||
### PASEO_HOME
|
||||
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
|
||||
Override knobs:
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
|
||||
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
|
||||
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
|
||||
```
|
||||
|
||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
||||
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
|
||||
and `config.json`; actual checkout/worktree directories are not copied.
|
||||
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
|
||||
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
|
||||
### Daemon endpoints
|
||||
|
||||
### Default ports
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In the main checkout:
|
||||
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
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.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the SQLite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
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
|
||||
|
||||
@@ -101,29 +89,25 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
npm run build:daemon
|
||||
```
|
||||
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
This rebuilds, in order, `@getpaseo/highlight` → `@getpaseo/relay` → `@getpaseo/server` → `@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
|
||||
|
||||
### Server → CLI
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
|
||||
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
|
||||
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
|
||||
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -177,7 +161,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ This file is auto-generated. Do not edit it by hand.
|
||||
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
|
||||
@@ -2,44 +2,28 @@
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Open question — per-host project entry (TBD)
|
||||
|
||||
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
|
||||
|
||||
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
|
||||
|
||||
## Rename before landing (in-progress `Checkout*` references)
|
||||
|
||||
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
|
||||
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
|
||||
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
|
||||
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
|
||||
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
|
||||
|
||||
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -143,7 +143,7 @@ Maestro `inputText` fires one character at a time. React Native's **controlled**
|
||||
|
||||
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
|
||||
|
||||
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
|
||||
### Dropdowns that launch native presenters (iOS)
|
||||
|
||||
|
||||
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`
|
||||
@@ -67,10 +67,14 @@ Anyone who builds software:
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
|
||||
@@ -6,15 +6,15 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-agent.ts`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +151,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
|
||||
### 3. Add the factory to the provider registry
|
||||
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry to `PROVIDER_CLIENT_FACTORIES`:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
@@ -161,11 +161,13 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
runtimeSettings,
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
|
||||
|
||||
### 4. Add a provider icon (app)
|
||||
|
||||
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||
@@ -187,19 +189,18 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
|
||||
|
||||
```ts
|
||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
// ... existing entries ...
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
@@ -219,25 +220,25 @@ export const agentConfigs = {
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
(await isCommandAvailable("my-agent-binary")) &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array:
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"pi",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
@@ -258,7 +259,9 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
The interfaces below are abridged signatures — read `agent-sdk-types.ts` for the full source of truth (option bag types, generics, etc.).
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model/mode listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
@@ -267,16 +270,19 @@ interface AgentClient {
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -287,6 +293,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
@@ -296,7 +303,10 @@ interface AgentSession {
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -304,6 +314,10 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ There are two supported ways to ship from `main`:
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
@@ -147,11 +147,16 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
|
||||
@@ -98,6 +98,37 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
### File naming
|
||||
|
||||
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
|
||||
|
||||
| Suffix | What it is | Where it runs |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
|
||||
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
|
||||
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
|
||||
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
|
||||
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
|
||||
|
||||
## Running tests locally
|
||||
|
||||
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
|
||||
|
||||
- Run only the file you changed: `npx vitest run <path> --bail=1`
|
||||
- Never run `npm run test` for a whole workspace unless asked.
|
||||
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
|
||||
|
||||
@@ -64,9 +64,9 @@ If you add a new `useUnistyles()` call, leave a comment on the line explaining w
|
||||
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
|
||||
|
||||
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
|
||||
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
|
||||
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, tabs row, deck wrapper
|
||||
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
@@ -124,18 +124,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||
|
||||
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
|
||||
|
||||
```tsx
|
||||
import { ScrollView } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
const ThemedScrollView = withUnistyles(ScrollView);
|
||||
|
||||
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
|
||||
```
|
||||
|
||||
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
@@ -155,7 +146,7 @@ Use this sparingly. It works because React re-renders the prop, but it gives up
|
||||
|
||||
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz {
|
||||
@@ -231,15 +222,49 @@ If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
|
||||
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Reanimated `Animated.View` + Dynamic Styles Crashes
|
||||
|
||||
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
|
||||
|
||||
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
|
||||
|
||||
```tsx
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
sidebar: { position: "absolute", inset: 0, width: 280 },
|
||||
});
|
||||
|
||||
function Sidebar() {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Animated.View
|
||||
style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
|
||||
@@ -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-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||
# 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).
|
||||
|
||||
2598
package-lock.json
generated
2598
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.75",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
21
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
21
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Resubmit iOS for App Store review
|
||||
|
||||
# Standalone re-trigger for the App Store review submission step. The iOS
|
||||
# binary is already uploaded to TestFlight via the main release-mobile flow;
|
||||
# this workflow just runs the fastlane submit_review lane against the latest
|
||||
# TestFlight build, without rebuilding or re-uploading.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
@@ -82,7 +82,7 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-issue-",
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
@@ -114,7 +114,7 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-pr-",
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
|
||||
@@ -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)"}`,
|
||||
|
||||
@@ -153,14 +153,19 @@ function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture
|
||||
return { number: issueNumber, title: spec.title, url: issueUrl };
|
||||
}
|
||||
|
||||
// Single namespace for temporary GitHub repos created by Paseo tests.
|
||||
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
|
||||
// (collides with real repos like `paseo`, `paseo-website`).
|
||||
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
|
||||
|
||||
export async function createTempGithubRepo(options: {
|
||||
prefix?: string;
|
||||
category: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
|
||||
const { category, prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${prefix}${uniqueSuffix}`;
|
||||
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
|
||||
@@ -170,9 +170,12 @@ export async function openNewWorkspaceComposer(
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(input.prompt ?? "Hello from e2e");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/utils/pr-pane-data";
|
||||
import { getStateLabel } from "@/git/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -306,6 +306,10 @@ test.describe("New workspace flow", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill("Hello from e2e");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("PR pane", () => {
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
|
||||
repoFixture = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-pr-",
|
||||
category: "pr-pane",
|
||||
prs: [
|
||||
{ title: "Review selected start ref", state: "open" },
|
||||
{ title: "Merged feature branch", state: "merged" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test } from "./fixtures";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
import { expect, type Page, test } from "./fixtures";
|
||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
@@ -8,6 +8,55 @@ import {
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
async function installCreateAgentRequestRecorder(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const requests: unknown[] = [];
|
||||
(
|
||||
window as typeof window & {
|
||||
__paseoE2eCreateAgentRequests?: unknown[];
|
||||
}
|
||||
).__paseoE2eCreateAgentRequests = requests;
|
||||
const originalSend = WebSocket.prototype.send;
|
||||
WebSocket.prototype.send = function (data) {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
type?: unknown;
|
||||
message?: { type?: unknown };
|
||||
};
|
||||
if (parsed.type === "session" && parsed.message?.type === "create_agent_request") {
|
||||
requests.push(parsed.message);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON frames.
|
||||
}
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getRecordedCreateAgentCwd(page: Page, message: string): Promise<string | null> {
|
||||
return page.evaluate((expectedMessage) => {
|
||||
const requests =
|
||||
(
|
||||
window as typeof window & {
|
||||
__paseoE2eCreateAgentRequests?: Array<{
|
||||
initialPrompt?: string;
|
||||
config?: { cwd?: string };
|
||||
}>;
|
||||
}
|
||||
).__paseoE2eCreateAgentRequests ?? [];
|
||||
|
||||
for (const request of requests) {
|
||||
if (request.initialPrompt === expectedMessage) {
|
||||
return request.config?.cwd ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, message);
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
@@ -26,6 +75,32 @@ test.describe("Workspace cwd correctness", () => {
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await installCreateAgentRequestRecorder(page);
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 })
|
||||
.toBe(workspace.repoPath);
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -25,17 +25,20 @@ platform :ios do
|
||||
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||
|
||||
valid_build = nil
|
||||
deadline = Time.now + (30 * 60)
|
||||
loop do
|
||||
builds = Spaceship::ConnectAPI::Build.all(
|
||||
app_id: app.id,
|
||||
filter: { "version" => build_number.to_s },
|
||||
includes: "preReleaseVersion",
|
||||
build_number: build_number.to_s,
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
break if state == "VALID"
|
||||
if state == "VALID"
|
||||
valid_build = build
|
||||
break
|
||||
end
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
@@ -45,9 +48,30 @@ platform :ios do
|
||||
sleep(30)
|
||||
end
|
||||
|
||||
app_version = valid_build.pre_release_version.version
|
||||
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
|
||||
|
||||
# Ensure an editable App Store version exists for this marketing version.
|
||||
# deliver requires a "Prepare for Submission" version on App Store Connect
|
||||
# and won't auto-create one when skip_metadata + skip_screenshots are set.
|
||||
app.ensure_version!(app_version, platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
|
||||
# Apple requires a non-empty "What's New" on every new App Store version
|
||||
# before it can be submitted for review. With skip_metadata: true, deliver
|
||||
# never sets this, so we populate it directly per-locale.
|
||||
edit_version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
release_notes = "Bug fixes and improvements."
|
||||
edit_version.get_app_store_version_localizations.each do |localization|
|
||||
if localization.whats_new.to_s.strip.empty?
|
||||
UI.message("Setting whatsNew on #{localization.locale}")
|
||||
localization.update(attributes: { whatsNew: release_notes })
|
||||
end
|
||||
end
|
||||
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
app_version: app_version,
|
||||
build_number: build_number.to_s,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"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));
|
||||
@@ -889,15 +869,20 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// PortalProvider must remain the innermost global provider here.
|
||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
||||
// Adding a new global provider? Put it above PortalProvider.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<PortalProvider>
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -75,6 +72,7 @@ function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
attentionTimestamp: agent.attentionTimestamp,
|
||||
archivedAt: agent.archivedAt,
|
||||
labels: agent.labels,
|
||||
parentAgentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,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),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -67,6 +67,22 @@ describe("combined model selector helpers", () => {
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||
const row = {
|
||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
||||
provider: "opencode",
|
||||
providerLabel: "OpenCode",
|
||||
modelId: "opencode-zen/kimi-k2.5",
|
||||
modelLabel: "Kimi K2.5",
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
@@ -48,7 +48,10 @@ export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): b
|
||||
return true;
|
||||
}
|
||||
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
}
|
||||
|
||||
@@ -1541,7 +1541,6 @@ export function Composer({
|
||||
|
||||
<View style={styles.messageInputContainer}>
|
||||
{autocompletePopover}
|
||||
{attachmentPreviewList}
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
@@ -1581,6 +1580,7 @@ export function Composer({
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentPreviewList}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
@@ -1621,7 +1621,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
overflow: "visible",
|
||||
padding: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
@@ -1684,16 +1685,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
imageThumbnail: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
imageThumbnailPlaceholder: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
githubPillBody: {
|
||||
minHeight: 48,
|
||||
minHeight: 32,
|
||||
maxWidth: 260,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "./pr-pane";
|
||||
import { PrPane } from "@/git/pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
|
||||
@@ -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)],
|
||||
[],
|
||||
|
||||
@@ -125,6 +125,8 @@ export interface MessageInputProps {
|
||||
onHeightChange?: (height: number) => void;
|
||||
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */
|
||||
attachmentSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -1094,6 +1096,7 @@ interface ResolvedMessageInputProps {
|
||||
onFocusChange: ((focused: boolean) => void) | undefined;
|
||||
onHeightChange: ((height: number) => void) | undefined;
|
||||
inputWrapperStyle: import("react-native").ViewStyle | undefined;
|
||||
attachmentSlot: React.ReactNode;
|
||||
}
|
||||
|
||||
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
|
||||
@@ -1133,6 +1136,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onFocusChange: props.onFocusChange,
|
||||
onHeightChange: props.onHeightChange,
|
||||
inputWrapperStyle: props.inputWrapperStyle,
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1180,6 +1184,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onFocusChange,
|
||||
onHeightChange,
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
} = resolveMessageInputProps(props);
|
||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
const toast = useToast();
|
||||
@@ -1697,6 +1702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||
{/* Regular input */}
|
||||
<Animated.View ref={inputWrapperRef} style={inputWrapperCombinedStyle}>
|
||||
{attachmentSlot}
|
||||
{/* Text input */}
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
|
||||
@@ -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,
|
||||
@@ -908,17 +903,34 @@ function MarkdownLink({
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
<a
|
||||
href={href}
|
||||
onClickCapture={preventAnchorNavigation}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={MARKDOWN_LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKDOWN_LINK_ANCHOR_STYLE: React.CSSProperties = {
|
||||
display: "contents",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function preventAnchorNavigation(event: React.MouseEvent<HTMLAnchorElement>): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string,
|
||||
@@ -2841,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>(() => {
|
||||
@@ -2858,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);
|
||||
@@ -2922,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(() => {
|
||||
@@ -2963,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"
|
||||
@@ -2984,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}
|
||||
|
||||
@@ -94,15 +94,18 @@ export function ProjectPickerModal() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
buildWorkingDirectorySuggestions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
}),
|
||||
[query, directorySuggestionsQuery.data, recommendedPaths],
|
||||
);
|
||||
const options = useMemo(() => {
|
||||
const suggestedPaths = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths,
|
||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||
query,
|
||||
});
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery || suggestedPaths.includes(trimmedQuery)) {
|
||||
return suggestedPaths;
|
||||
}
|
||||
return [trimmedQuery, ...suggestedPaths];
|
||||
}, [query, directorySuggestionsQuery.data, recommendedPaths]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -239,6 +242,8 @@ export function ProjectPickerModal() {
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
editable={!isSubmitting}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleSubmitCustom}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -95,22 +95,21 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
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) {
|
||||
|
||||
77
packages/app/src/components/ui/external-link.tsx
Normal file
77
packages/app/src/components/ui/external-link.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { ArrowUpRight } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface ExternalLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
tooltip?: ReactNode;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the
|
||||
* URL via the platform's external opener. Wrap in a Tooltip when there's a
|
||||
* one-line hint worth surfacing on hover/tap.
|
||||
*/
|
||||
export function ExternalLink({
|
||||
href,
|
||||
label,
|
||||
tooltip,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: ExternalLinkProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(href);
|
||||
}, [href]);
|
||||
|
||||
const trigger = (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
testID={testID}
|
||||
style={styles.trigger}
|
||||
>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<ArrowUpRight size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="end" offset={6}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
maxWidth: 280,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -145,6 +145,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: "100%",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
gap: 2,
|
||||
},
|
||||
containerSm: {
|
||||
padding: 2,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Pressable } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -151,7 +152,6 @@ export function WorkspaceSetupDialog() {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
||||
initialCwd: sourceDirectory,
|
||||
composer: buildChatDraftComposerArgs({
|
||||
serverId,
|
||||
isConnected,
|
||||
@@ -191,6 +191,14 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
if (target.kind === "agent") {
|
||||
navigateToAgent({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
@@ -393,7 +401,7 @@ export function WorkspaceSetupDialog() {
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={chatDraft.cwd}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
title: "Agent",
|
||||
cwd: "/tmp",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
projectPlacement: 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
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,34 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
type SkillOp,
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
const UNINSTALL_MESSAGE =
|
||||
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
|
||||
|
||||
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
|
||||
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
|
||||
add: "Add skill",
|
||||
update: "Update skill",
|
||||
delete: "Delete skill",
|
||||
};
|
||||
|
||||
function formatUpdateMessage(ops: readonly SkillOp[]): string {
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
|
||||
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
|
||||
});
|
||||
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -25,16 +47,18 @@ export function IntegrationsSection() {
|
||||
} = useCliInstall();
|
||||
const {
|
||||
status: skillsStatus,
|
||||
isInstalling: isInstallingSkills,
|
||||
isWorking: isSkillsWorking,
|
||||
install: installSkills,
|
||||
update: updateSkills,
|
||||
uninstall: uninstallSkills,
|
||||
refresh: refreshSkillsStatus,
|
||||
} = useSkillsInstall();
|
||||
} = useSkillsStatus();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
refreshCliStatus();
|
||||
refreshSkillsStatus();
|
||||
void refreshSkillsStatus();
|
||||
return undefined;
|
||||
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
|
||||
);
|
||||
@@ -45,9 +69,33 @@ export function IntegrationsSection() {
|
||||
}, [installCli, isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isInstallingSkills) return;
|
||||
installSkills();
|
||||
}, [installSkills, isInstallingSkills]);
|
||||
if (isSkillsWorking) return;
|
||||
void installSkills();
|
||||
}, [installSkills, isSkillsWorking]);
|
||||
|
||||
const handleUpdateSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const ops = skillsStatus?.ops ?? [];
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Update Paseo skills?",
|
||||
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
|
||||
confirmLabel: "Update",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await updateSkills();
|
||||
}, [isSkillsWorking, skillsStatus, updateSkills]);
|
||||
|
||||
const handleUninstallSkills = useCallback(async () => {
|
||||
if (isSkillsWorking) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Uninstall Paseo skills?",
|
||||
message: UNINSTALL_MESSAGE,
|
||||
confirmLabel: "Uninstall",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await uninstallSkills();
|
||||
}, [isSkillsWorking, uninstallSkills]);
|
||||
|
||||
const handleOpenCliDocs = useCallback(() => {
|
||||
void openExternalUrl(CLI_DOCS_URL);
|
||||
@@ -96,6 +144,8 @@ export function IntegrationsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillsState = skillsStatus?.state ?? null;
|
||||
|
||||
return (
|
||||
<SettingsSection title="Integrations" trailing={trailing}>
|
||||
<View style={settingsStyles.card}>
|
||||
@@ -130,30 +180,69 @@ export function IntegrationsSection() {
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI
|
||||
{skillsState === "drift"
|
||||
? "Update available"
|
||||
: "Teach your agents to orchestrate through the CLI"}
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
<SkillsActions
|
||||
state={skillsState}
|
||||
isWorking={isSkillsWorking}
|
||||
onInstall={handleInstallSkills}
|
||||
onUpdate={handleUpdateSkills}
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillsActionsProps {
|
||||
state: SkillsStatus["state"] | null;
|
||||
isWorking: boolean;
|
||||
onInstall: () => void;
|
||||
onUpdate: () => void;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
function SkillsActions({ state, isWorking, onInstall, onUpdate, onUninstall }: SkillsActionsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (state === "up-to-date") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "drift") {
|
||||
return (
|
||||
<View style={styles.actionsRow}>
|
||||
<Button variant="outline" size="sm" onPress={onUpdate} disabled={isWorking}>
|
||||
{isWorking ? "Working..." : "Update"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={onUninstall} disabled={isWorking}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onPress={onInstall} disabled={isWorking}>
|
||||
{isWorking ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
@@ -174,4 +263,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -220,10 +220,67 @@ export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
export type SkillsState = "not-installed" | "up-to-date" | "drift";
|
||||
|
||||
export type SkillOp =
|
||||
| { kind: "add"; name: string }
|
||||
| { kind: "update"; name: string }
|
||||
| { kind: "delete"; name: string };
|
||||
|
||||
export interface SkillsStatus {
|
||||
state: SkillsState;
|
||||
ops: SkillOp[];
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
function parseSkillsState(value: unknown): SkillsState {
|
||||
switch (value) {
|
||||
case "not-installed":
|
||||
case "up-to-date":
|
||||
case "drift":
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unexpected skills status state: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillOp(raw: unknown): SkillOp {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skill op response.");
|
||||
}
|
||||
const name = toStringOrNull(raw.name);
|
||||
if (!name) throw new Error("Skill op missing name.");
|
||||
switch (raw.kind) {
|
||||
case "add":
|
||||
return { kind: "add", name };
|
||||
case "update":
|
||||
return { kind: "update", name };
|
||||
case "delete":
|
||||
return { kind: "delete", name };
|
||||
default:
|
||||
throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSkillsStatus(raw: unknown): SkillsStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected skills status response.");
|
||||
}
|
||||
const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [];
|
||||
return { state: parseSkillsState(raw.state), ops };
|
||||
}
|
||||
|
||||
export async function getSkillsStatus(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("get_skills_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
export async function updateSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("update_skills"));
|
||||
}
|
||||
|
||||
export async function uninstallSkills(): Promise<SkillsStatus> {
|
||||
return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCliInstall, useSkillsInstall } from "./use-install-status";
|
||||
import { useCliInstall, useSkillsStatus } from "./use-install-status";
|
||||
|
||||
const toast = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
@@ -15,9 +15,11 @@ const toast = vi.hoisted(() => ({
|
||||
|
||||
const desktopDaemon = vi.hoisted(() => ({
|
||||
getCliInstallStatus: vi.fn(),
|
||||
getSkillsInstallStatus: vi.fn(),
|
||||
installCli: vi.fn(),
|
||||
getSkillsStatus: vi.fn(),
|
||||
installSkills: vi.fn(),
|
||||
updateSkills: vi.fn(),
|
||||
uninstallSkills: vi.fn(),
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
@@ -89,11 +91,9 @@ describe("useCliInstall", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSkillsInstall", () => {
|
||||
describe("useSkillsStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
|
||||
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,34 +101,155 @@ describe("useSkillsInstall", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads skills install status", async () => {
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: true });
|
||||
it("loads the current skills status", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "up-to-date",
|
||||
ops: [],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and exposes skills install errors", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
const { result } = renderDesktopHook(() => useSkillsInstall());
|
||||
it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ installed: false });
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.installSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("update transitions drift to up-to-date", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "drift",
|
||||
ops: [{ kind: "update", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.updateSkills.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("drift");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.updateSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it("uninstall transitions up-to-date back to not-installed", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] });
|
||||
desktopDaemon.uninstallSkills.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("up-to-date");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uninstall();
|
||||
});
|
||||
|
||||
expect(desktopDaemon.uninstallSkills).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isWorking flips while a mutation is in flight", async () => {
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
|
||||
let resolveInstall: ((value: unknown) => void) | null = null;
|
||||
desktopDaemon.installSkills.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveInstall = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
|
||||
let installPromise: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
result.current.install();
|
||||
installPromise = result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveInstall?.({ state: "up-to-date", ops: [] });
|
||||
await installPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isWorking).toBe(false);
|
||||
});
|
||||
expect(result.current.status).toEqual({ state: "up-to-date", ops: [] });
|
||||
});
|
||||
|
||||
it("toasts and exposes errors when install fails", async () => {
|
||||
const error = new Error("Missing IPC handler");
|
||||
desktopDaemon.getSkillsStatus.mockResolvedValue({
|
||||
state: "not-installed",
|
||||
ops: [{ kind: "add", name: "paseo" }],
|
||||
});
|
||||
desktopDaemon.installSkills.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderDesktopHook(() => useSkillsStatus());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.status?.state).toBe("not-installed");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.install();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(error);
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
|
||||
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCliInstallStatus,
|
||||
getSkillsInstallStatus,
|
||||
getSkillsStatus,
|
||||
installCli,
|
||||
installSkills,
|
||||
shouldUseDesktopDaemon,
|
||||
type InstallStatus,
|
||||
type SkillsStatus,
|
||||
uninstallSkills,
|
||||
updateSkills,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
useDesktopIpcErrorReporter,
|
||||
@@ -14,11 +17,7 @@ import {
|
||||
} from "@/desktop/hooks/desktop-ipc-error";
|
||||
|
||||
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
|
||||
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
|
||||
"desktop",
|
||||
"integrations",
|
||||
"skills-install-status",
|
||||
] as const;
|
||||
const SKILLS_STATUS_QUERY_KEY = ["desktop", "integrations", "skills-status"] as const;
|
||||
|
||||
interface DesktopInstallHookResult {
|
||||
status: InstallStatus | null;
|
||||
@@ -77,25 +76,43 @@ export function useCliInstall(): DesktopInstallHookResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
export interface SkillsStatusHookResult {
|
||||
status: SkillsStatus | null;
|
||||
isLoading: boolean;
|
||||
isWorking: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
install: () => Promise<void>;
|
||||
update: () => Promise<void>;
|
||||
uninstall: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSkillsStatus(): SkillsStatusHookResult {
|
||||
const queryClient = useQueryClient();
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const statusQuery = useQuery<InstallStatus, Error>({
|
||||
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsInstallStatus,
|
||||
const statusQuery = useQuery<SkillsStatus, Error>({
|
||||
queryKey: SKILLS_STATUS_QUERY_KEY,
|
||||
queryFn: getSkillsStatus,
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
|
||||
const { data: status, error: statusError, isLoading, refetch } = statusQuery;
|
||||
useDesktopIpcQueryErrorToast({
|
||||
error: statusQuery.error,
|
||||
message: "Unable to check orchestration skills install status.",
|
||||
message: "Unable to check orchestration skills status.",
|
||||
logLabel: "[Integrations] Failed to load skills status",
|
||||
});
|
||||
|
||||
const installMutation = useMutation<InstallStatus, Error>({
|
||||
const setStatus = useCallback(
|
||||
(next: SkillsStatus) => {
|
||||
queryClient.setQueryData<SkillsStatus>(SKILLS_STATUS_QUERY_KEY, next);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const installMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: installSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
@@ -104,23 +121,65 @@ export function useSkillsInstall(): DesktopInstallHookResult {
|
||||
logLabel: "[Integrations] Failed to install skills",
|
||||
});
|
||||
},
|
||||
onSuccess: (nextStatus) => {
|
||||
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
|
||||
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void refetch();
|
||||
const updateMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: updateSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to update orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to update skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation<SkillsStatus, Error>({
|
||||
mutationFn: uninstallSkills,
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to uninstall orchestration skills.",
|
||||
logLabel: "[Integrations] Failed to uninstall skills",
|
||||
});
|
||||
},
|
||||
onSuccess: setStatus,
|
||||
});
|
||||
|
||||
const isWorking =
|
||||
installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const install = useCallback(async () => {
|
||||
await installMutation.mutateAsync().catch(() => undefined);
|
||||
}, [installMutation]);
|
||||
|
||||
const update = useCallback(async () => {
|
||||
await updateMutation.mutateAsync().catch(() => undefined);
|
||||
}, [updateMutation]);
|
||||
|
||||
const uninstall = useCallback(async () => {
|
||||
await uninstallMutation.mutateAsync().catch(() => undefined);
|
||||
}, [uninstallMutation]);
|
||||
|
||||
return {
|
||||
status: installStatus ?? null,
|
||||
status: status ?? null,
|
||||
isLoading,
|
||||
isInstalling,
|
||||
error: statusError ?? installError ?? null,
|
||||
install,
|
||||
isWorking,
|
||||
error:
|
||||
statusError ??
|
||||
installMutation.error ??
|
||||
updateMutation.error ??
|
||||
uninstallMutation.error ??
|
||||
null,
|
||||
refresh,
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,21 +184,6 @@ describe("UpdateCalloutSource", () => {
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the changelog action once the update is installed", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
status: "installed",
|
||||
availableUpdate: null,
|
||||
};
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.textContent).toContain("Update installed");
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
||||
).toContain("What's new");
|
||||
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the install action and shows Installing... while installing", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
|
||||
@@ -13,25 +13,18 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function resolveUpdateCalloutTitle(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
}): string {
|
||||
if (args.isInstalled) return "Update installed";
|
||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
||||
if (args.isInstalling) return "Installing update";
|
||||
if (args.isError) return "Update failed";
|
||||
return "Update available";
|
||||
}
|
||||
|
||||
function resolveUpdateCalloutDescription(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
latestVersion: string | undefined;
|
||||
}): ReactNode {
|
||||
if (args.isInstalled) return "Restart to use the new version.";
|
||||
if (args.isInstalling) return "Installing and restarting...";
|
||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||
if (args.latestVersion) {
|
||||
@@ -43,7 +36,6 @@ function resolveUpdateCalloutDescription(args: {
|
||||
}
|
||||
|
||||
function buildUpdateCalloutActions(args: {
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
openChangelog: () => void;
|
||||
@@ -53,7 +45,7 @@ function buildUpdateCalloutActions(args: {
|
||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||
if (args.isError) {
|
||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||
} else if (!args.isInstalled) {
|
||||
} else {
|
||||
actions.push({
|
||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||
onPress: args.install,
|
||||
@@ -107,29 +99,21 @@ export function UpdateCalloutSource() {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
status !== "available" &&
|
||||
status !== "installed" &&
|
||||
status !== "installing" &&
|
||||
status !== "error"
|
||||
) {
|
||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInstalled = status === "installed";
|
||||
const isError = status === "error";
|
||||
const isAvailable = !isInstalled && !isInstalling && !isError;
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
|
||||
const title = resolveUpdateCalloutTitle({ isInstalled, isInstalling, isError });
|
||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
||||
const description = resolveUpdateCalloutDescription({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
errorMessage,
|
||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||
});
|
||||
const actions = buildUpdateCalloutActions({
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
isError,
|
||||
openChangelog,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -163,9 +163,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
action={action}
|
||||
onSelect={handleActionSelect}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
needsSeparator={
|
||||
action.id === "merge-from-base" || action.id === "archive-worktree"
|
||||
}
|
||||
needsSeparator={action.startsGroup}
|
||||
showSeparator={index > 0}
|
||||
closeOnSelect={
|
||||
action.status === "idle" && action.id === "pr" && action.label === "View PR"
|
||||
@@ -1,15 +1,13 @@
|
||||
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 "@/stores/checkout-git-actions-store";
|
||||
} from "@/git/actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
@@ -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,5 @@
|
||||
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";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
resolveWorkspaceIdByExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
|
||||
const SUCCESS_DISPLAY_MS = 1000;
|
||||
|
||||
@@ -27,6 +29,9 @@ export type CheckoutGitAsyncActionId =
|
||||
| "push"
|
||||
| "pull-and-push"
|
||||
| "create-pr"
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -70,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 });
|
||||
}
|
||||
@@ -259,6 +226,11 @@ interface CheckoutGitActionsStoreState {
|
||||
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergePr: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
@@ -405,6 +377,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
mergePr: async ({ serverId, cwd, method }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: `merge-pr-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPrMerge(cwd, { method });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./diff-order";
|
||||
|
||||
function createFile(path: string, additions = 0) {
|
||||
return {
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type ViewStyle,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -45,20 +44,16 @@ import {
|
||||
Upload,
|
||||
WrapText,
|
||||
} from "lucide-react-native";
|
||||
import {
|
||||
useCheckoutGitActionsStore,
|
||||
type CheckoutGitActionStatus,
|
||||
} from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
} from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "./diff-scroll";
|
||||
import { DiffScroll } from "@/components/diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
@@ -66,7 +61,7 @@ import {
|
||||
} from "@getpaseo/highlight";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
@@ -83,16 +78,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
@@ -120,11 +111,7 @@ import {
|
||||
type InlineReviewActions,
|
||||
} from "@/review";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
|
||||
@@ -1274,16 +1261,6 @@ function getDiffContentLength(file: ParsedDiffFile): number {
|
||||
return contentLength;
|
||||
}
|
||||
|
||||
function computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string {
|
||||
if (currentBranch && currentBranch !== "HEAD") {
|
||||
return currentBranch;
|
||||
}
|
||||
if (notGit) {
|
||||
return "Not a git repository";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function computeEmptyMessage(
|
||||
hideWhitespace: boolean,
|
||||
diffMode: "uncommitted" | "base",
|
||||
@@ -1411,194 +1388,6 @@ function DiffBodyContent({
|
||||
);
|
||||
}
|
||||
|
||||
interface GitActionRunners {
|
||||
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
|
||||
runPull: (args: { serverId: string; cwd: string }) => Promise<void>;
|
||||
runPush: (args: { serverId: string; cwd: string }) => Promise<void>;
|
||||
runPullAndPush: (args: { serverId: string; cwd: string }) => Promise<void>;
|
||||
runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>;
|
||||
runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
runArchiveWorktree: (args: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
interface GitActionHandlersDeps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
baseRef: string | undefined;
|
||||
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
|
||||
runners: GitActionRunners;
|
||||
persistShipDefault: (next: "merge" | "pr") => Promise<void>;
|
||||
toastError: (message: string) => void;
|
||||
toastActionError: (error: unknown, fallback: string) => void;
|
||||
toastActionSuccess: (message: string) => void;
|
||||
onMergeBranchSuccess: () => void;
|
||||
onArchiveSuccess: (targetWorkingDir: string) => void;
|
||||
}
|
||||
|
||||
interface GitActionHandlers {
|
||||
handleCommit: () => void;
|
||||
handlePull: () => void;
|
||||
handlePush: () => void;
|
||||
handlePullAndPush: () => void;
|
||||
handleCreatePr: () => void;
|
||||
handleMergeBranch: () => void;
|
||||
handleMergeFromBase: () => void;
|
||||
handleArchiveWorktree: () => void;
|
||||
}
|
||||
|
||||
function useGitActionHandlers({
|
||||
serverId,
|
||||
cwd,
|
||||
baseRef,
|
||||
status,
|
||||
runners,
|
||||
persistShipDefault,
|
||||
toastError,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
onMergeBranchSuccess,
|
||||
onArchiveSuccess,
|
||||
}: GitActionHandlersDeps): GitActionHandlers {
|
||||
const handleCommit = useCallback(() => {
|
||||
void runners
|
||||
.runCommit({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Committed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to commit");
|
||||
});
|
||||
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
void runners
|
||||
.runPull({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pulled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to pull");
|
||||
});
|
||||
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePush = useCallback(() => {
|
||||
void runners
|
||||
.runPush({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pushed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to push");
|
||||
});
|
||||
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePullAndPush = useCallback(() => {
|
||||
void runners
|
||||
.runPullAndPush({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pulled and pushed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to pull and push");
|
||||
});
|
||||
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
void runners
|
||||
.runCreatePr({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("PR created");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to create PR");
|
||||
});
|
||||
}, [cwd, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toastError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
void runners
|
||||
.runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
onMergeBranchSuccess();
|
||||
toastActionSuccess("Merged");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to merge");
|
||||
});
|
||||
}, [
|
||||
baseRef,
|
||||
cwd,
|
||||
onMergeBranchSuccess,
|
||||
persistShipDefault,
|
||||
runners,
|
||||
serverId,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
toastError,
|
||||
]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toastError("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void runners
|
||||
.runMergeFromBase({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
toastActionSuccess("Updated");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to merge from base");
|
||||
});
|
||||
}, [baseRef, cwd, runners, serverId, toastActionError, toastActionSuccess, toastError]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
toastError("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runners
|
||||
.runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
onArchiveSuccess(targetWorkingDir);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [cwd, onArchiveSuccess, runners, serverId, status, toastActionError, toastError]);
|
||||
|
||||
return {
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeriveStatusStateInputs {
|
||||
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
|
||||
isStatusLoading: boolean;
|
||||
@@ -1642,35 +1431,6 @@ function deriveStatusState({
|
||||
};
|
||||
}
|
||||
|
||||
interface DerivedBranchState {
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
hasPullRequest: boolean;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isMergedPullRequest: boolean;
|
||||
currentBranch: string | null | undefined;
|
||||
}
|
||||
|
||||
function deriveBranchState(
|
||||
gitStatus: DerivedStatusState["gitStatus"],
|
||||
prStatus: { url?: string | null; isMerged?: boolean | null } | null | undefined,
|
||||
): DerivedBranchState {
|
||||
return {
|
||||
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
|
||||
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
|
||||
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
|
||||
hasPullRequest: Boolean(prStatus?.url),
|
||||
hasRemote: gitStatus?.hasRemote ?? false,
|
||||
isPaseoOwnedWorktree: gitStatus?.isPaseoOwnedWorktree ?? false,
|
||||
isMergedPullRequest: Boolean(prStatus?.isMerged),
|
||||
currentBranch: gitStatus?.currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function computeBaseRefLabel(baseRef: string | undefined): string {
|
||||
if (!baseRef) return "base";
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
@@ -1687,36 +1447,6 @@ function computeCommittedDiffDescription(
|
||||
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
|
||||
}
|
||||
|
||||
function computeShouldPromoteArchive(
|
||||
isPaseoOwnedWorktree: boolean,
|
||||
hasUncommittedChanges: boolean,
|
||||
postShipArchiveSuggested: boolean,
|
||||
isMergedPullRequest: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
isPaseoOwnedWorktree &&
|
||||
!hasUncommittedChanges &&
|
||||
(postShipArchiveSuggested || isMergedPullRequest)
|
||||
);
|
||||
}
|
||||
|
||||
function computeDisabledStates(
|
||||
actionsDisabled: boolean,
|
||||
statuses: GitActionsStatusInputs,
|
||||
): GitActionsDisabledInputs {
|
||||
const pending = "pending";
|
||||
return {
|
||||
commitDisabled: actionsDisabled || statuses.commitStatus === pending,
|
||||
pullDisabled: actionsDisabled || statuses.pullStatus === pending,
|
||||
pushDisabled: actionsDisabled || statuses.pushStatus === pending,
|
||||
pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending,
|
||||
prDisabled: actionsDisabled || statuses.prCreateStatus === pending,
|
||||
mergeDisabled: actionsDisabled || statuses.mergeStatus === pending,
|
||||
mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending,
|
||||
archiveDisabled: actionsDisabled || statuses.archiveStatus === pending,
|
||||
};
|
||||
}
|
||||
|
||||
function computePrErrorMessage(
|
||||
githubFeaturesEnabled: boolean,
|
||||
prPayloadError: { message?: string } | null | undefined,
|
||||
@@ -1736,128 +1466,6 @@ function buildToggleButtonStyle(
|
||||
];
|
||||
}
|
||||
|
||||
function createPrHandler(
|
||||
prStatus: { url?: string | null } | null | undefined,
|
||||
handleCreatePr: () => void,
|
||||
): () => void {
|
||||
return () => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
};
|
||||
}
|
||||
|
||||
interface GitActionsStatusInputs {
|
||||
commitStatus: CheckoutGitActionStatus;
|
||||
pullStatus: CheckoutGitActionStatus;
|
||||
pushStatus: CheckoutGitActionStatus;
|
||||
pullAndPushStatus: CheckoutGitActionStatus;
|
||||
prCreateStatus: CheckoutGitActionStatus;
|
||||
mergeStatus: CheckoutGitActionStatus;
|
||||
mergeFromBaseStatus: CheckoutGitActionStatus;
|
||||
archiveStatus: CheckoutGitActionStatus;
|
||||
}
|
||||
|
||||
interface GitActionsDisabledInputs {
|
||||
commitDisabled: boolean;
|
||||
pullDisabled: boolean;
|
||||
pushDisabled: boolean;
|
||||
pullAndPushDisabled: boolean;
|
||||
prDisabled: boolean;
|
||||
mergeDisabled: boolean;
|
||||
mergeFromBaseDisabled: boolean;
|
||||
archiveDisabled: boolean;
|
||||
}
|
||||
|
||||
interface BuildGitActionsParams {
|
||||
policy: {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
shouldPromoteArchive: boolean;
|
||||
shipDefault: "merge" | "pr";
|
||||
};
|
||||
statuses: GitActionsStatusInputs;
|
||||
disabled: GitActionsDisabledInputs;
|
||||
handlers: GitActionHandlers & { handlePr: () => void };
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
function buildGitActionsForPane({
|
||||
policy,
|
||||
statuses,
|
||||
disabled,
|
||||
handlers,
|
||||
iconColor,
|
||||
}: BuildGitActionsParams): GitActions {
|
||||
return buildGitActions({
|
||||
...policy,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: disabled.commitDisabled,
|
||||
status: statuses.commitStatus,
|
||||
icon: <GitCommitHorizontal size={16} color={iconColor} />,
|
||||
handler: handlers.handleCommit,
|
||||
},
|
||||
pull: {
|
||||
disabled: disabled.pullDisabled,
|
||||
status: statuses.pullStatus,
|
||||
icon: <Download size={16} color={iconColor} />,
|
||||
handler: handlers.handlePull,
|
||||
},
|
||||
push: {
|
||||
disabled: disabled.pushDisabled,
|
||||
status: statuses.pushStatus,
|
||||
icon: <Upload size={16} color={iconColor} />,
|
||||
handler: handlers.handlePush,
|
||||
},
|
||||
"pull-and-push": {
|
||||
disabled: disabled.pullAndPushDisabled,
|
||||
status: statuses.pullAndPushStatus,
|
||||
icon: <ArrowDownUp size={16} color={iconColor} />,
|
||||
handler: handlers.handlePullAndPush,
|
||||
},
|
||||
pr: {
|
||||
disabled: disabled.prDisabled,
|
||||
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
|
||||
icon: <GitHubIcon size={16} color={iconColor} />,
|
||||
handler: handlers.handlePr,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: disabled.mergeDisabled,
|
||||
status: statuses.mergeStatus,
|
||||
icon: <GitMerge size={16} color={iconColor} />,
|
||||
handler: handlers.handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: disabled.mergeFromBaseDisabled,
|
||||
status: statuses.mergeFromBaseStatus,
|
||||
icon: <RefreshCcw size={16} color={iconColor} />,
|
||||
handler: handlers.handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: disabled.archiveDisabled,
|
||||
status: statuses.archiveStatus,
|
||||
icon: <Archive size={16} color={iconColor} />,
|
||||
handler: handlers.handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
|
||||
return input.paneEnabled && input.isGit;
|
||||
}
|
||||
@@ -1870,13 +1478,10 @@ export function GitDiffPane({
|
||||
enabled,
|
||||
}: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
const canUseSplitLayout = isWeb && !isMobile;
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
useChangesPreferences();
|
||||
const wrapLines = changesPreferences.wrapLines;
|
||||
@@ -1943,15 +1548,7 @@ export function GitDiffPane({
|
||||
error: statusError,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
|
||||
const {
|
||||
gitStatus,
|
||||
isGit,
|
||||
notGit,
|
||||
statusErrorMessage,
|
||||
baseRef,
|
||||
hasUncommittedChanges,
|
||||
actionsDisabled,
|
||||
} = statusState;
|
||||
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
|
||||
|
||||
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
||||
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
|
||||
@@ -2041,11 +1638,7 @@ export function GitDiffPane({
|
||||
setWorkspaceAttachments,
|
||||
workspaceAttachmentScopeKey,
|
||||
]);
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
@@ -2079,45 +1672,6 @@ export function GitDiffPane({
|
||||
const diffBodyLineHeight = theme.lineHeight.diff;
|
||||
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
|
||||
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
if (!gitStatus?.repoRoot) {
|
||||
return null;
|
||||
}
|
||||
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
|
||||
}, [gitStatus?.repoRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shipDefaultStorageKey) {
|
||||
return;
|
||||
}
|
||||
let isActive = true;
|
||||
AsyncStorage.getItem(shipDefaultStorageKey)
|
||||
.then((value) => {
|
||||
if (!isActive) return;
|
||||
if (value === "pr" || value === "merge") {
|
||||
setShipDefault(value);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [shipDefaultStorageKey]);
|
||||
|
||||
const persistShipDefault = useCallback(
|
||||
async (next: "merge" | "pr") => {
|
||||
setShipDefault(next);
|
||||
if (!shipDefaultStorageKey) return;
|
||||
try {
|
||||
await AsyncStorage.setItem(shipDefaultStorageKey, next);
|
||||
} catch {
|
||||
// Ignore persistence failures; default will reset to "merge".
|
||||
}
|
||||
},
|
||||
[shipDefaultStorageKey],
|
||||
);
|
||||
|
||||
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
||||
const items: DiffFlatItem[] = [];
|
||||
const stickyIndices: number[] = [];
|
||||
@@ -2304,119 +1858,6 @@ export function GitDiffPane({
|
||||
setDiffModeOverride(null);
|
||||
}, [autoDiffMode]);
|
||||
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||
);
|
||||
const pullStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||
);
|
||||
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||
);
|
||||
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||
);
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||
const runPull = useCheckoutGitActionsStore((state) => state.pull);
|
||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
|
||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
const toastActionError = useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
toast.error(message);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const toastActionSuccess = useCallback(
|
||||
(message: string) => {
|
||||
toast.show(message, { variant: "success" });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const handleMergeBranchSuccess = useCallback(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
}, []);
|
||||
|
||||
const handleArchiveSuccess = useCallback(
|
||||
(targetWorkingDir: string) => {
|
||||
navigateToWorkspace(serverId, targetWorkingDir);
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const toastError = useCallback(
|
||||
(message: string) => {
|
||||
toast.error(message);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const runners = useMemo<GitActionRunners>(
|
||||
() => ({
|
||||
runCommit,
|
||||
runPull,
|
||||
runPush,
|
||||
runPullAndPush,
|
||||
runCreatePr,
|
||||
runMergeBranch,
|
||||
runMergeFromBase,
|
||||
runArchiveWorktree,
|
||||
}),
|
||||
[
|
||||
runArchiveWorktree,
|
||||
runCommit,
|
||||
runCreatePr,
|
||||
runMergeBranch,
|
||||
runMergeFromBase,
|
||||
runPull,
|
||||
runPullAndPush,
|
||||
runPush,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
} = useGitActionHandlers({
|
||||
serverId,
|
||||
cwd,
|
||||
baseRef,
|
||||
status,
|
||||
runners,
|
||||
persistShipDefault,
|
||||
toastError,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
onMergeBranchSuccess: handleMergeBranchSuccess,
|
||||
onArchiveSuccess: handleArchiveSuccess,
|
||||
});
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
if (item.type === "header") {
|
||||
@@ -2499,59 +1940,30 @@ export function GitDiffPane({
|
||||
const hasChanges = files.length > 0;
|
||||
const diffErrorMessage = diffPayloadError?.message ?? null;
|
||||
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
|
||||
const branchLabel = computeBranchLabel(gitStatus?.currentBranch, notGit);
|
||||
const branchState = useMemo(() => deriveBranchState(gitStatus, prStatus), [gitStatus, prStatus]);
|
||||
const {
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
hasPullRequest,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isMergedPullRequest,
|
||||
currentBranch,
|
||||
} = branchState;
|
||||
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const gitActionsIcons = useMemo(
|
||||
() => ({
|
||||
commit: <GitCommitHorizontal size={16} color={iconColor} />,
|
||||
pull: <Download size={16} color={iconColor} />,
|
||||
push: <Upload size={16} color={iconColor} />,
|
||||
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
|
||||
viewPr: <GitHubIcon size={16} color={iconColor} />,
|
||||
createPr: <GitHubIcon size={16} color={iconColor} />,
|
||||
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
|
||||
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
|
||||
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
|
||||
merge: <GitMerge size={16} color={iconColor} />,
|
||||
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
|
||||
archive: <Archive size={16} color={iconColor} />,
|
||||
}),
|
||||
[iconColor],
|
||||
);
|
||||
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
|
||||
const committedDiffDescription = useMemo(
|
||||
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
||||
[baseRefLabel, branchLabel],
|
||||
);
|
||||
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||
const shouldPromoteArchive = computeShouldPromoteArchive(
|
||||
isPaseoOwnedWorktree,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isMergedPullRequest,
|
||||
);
|
||||
|
||||
const gitActionsStatuses = useMemo<GitActionsStatusInputs>(
|
||||
() => ({
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
}),
|
||||
[
|
||||
archiveStatus,
|
||||
commitStatus,
|
||||
mergeFromBaseStatus,
|
||||
mergeStatus,
|
||||
prCreateStatus,
|
||||
pullAndPushStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const gitActionsDisabled = useMemo<GitActionsDisabledInputs>(
|
||||
() => computeDisabledStates(actionsDisabled, gitActionsStatuses),
|
||||
[actionsDisabled, gitActionsStatuses],
|
||||
);
|
||||
|
||||
const emptyMessage = computeEmptyMessage(
|
||||
changesPreferences.hideWhitespace,
|
||||
@@ -2583,101 +1995,6 @@ export function GitDiffPane({
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
// ==========================================================================
|
||||
// Git Actions (Data-Oriented)
|
||||
// ==========================================================================
|
||||
// All possible actions are computed as data, then partitioned into:
|
||||
// - primary: The main CTA button
|
||||
// - secondary: Dropdown next to primary button
|
||||
// - menu: Kebab overflow menu
|
||||
// ==========================================================================
|
||||
|
||||
const handlePr = useMemo(
|
||||
() => createPrHandler(prStatus, handleCreatePr),
|
||||
[handleCreatePr, prStatus],
|
||||
);
|
||||
|
||||
const gitActionsHandlers = useMemo(
|
||||
() => ({
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
handlePr,
|
||||
}),
|
||||
[
|
||||
handleArchiveWorktree,
|
||||
handleCommit,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handlePr,
|
||||
handlePull,
|
||||
handlePullAndPush,
|
||||
handlePush,
|
||||
],
|
||||
);
|
||||
|
||||
const gitActions: GitActions = useMemo(
|
||||
() =>
|
||||
buildGitActionsForPane({
|
||||
policy: {
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
},
|
||||
statuses: gitActionsStatuses,
|
||||
disabled: gitActionsDisabled,
|
||||
handlers: gitActionsHandlers,
|
||||
iconColor: theme.colors.foregroundMuted,
|
||||
}),
|
||||
[
|
||||
aheadCount,
|
||||
aheadOfOrigin,
|
||||
baseRef,
|
||||
baseRefLabel,
|
||||
behindBaseCount,
|
||||
behindOfOrigin,
|
||||
gitActionsDisabled,
|
||||
gitActionsHandlers,
|
||||
gitActionsStatuses,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
hasRemote,
|
||||
hasUncommittedChanges,
|
||||
isGit,
|
||||
isOnBaseBranch,
|
||||
isPaseoOwnedWorktree,
|
||||
prStatus?.url,
|
||||
shipDefault,
|
||||
shouldPromoteArchive,
|
||||
theme.colors.foregroundMuted,
|
||||
],
|
||||
);
|
||||
|
||||
// Helper to get display label based on status
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{!hideHeaderRow ? (
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll";
|
||||
|
||||
describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
it("skips anchor when header is fully visible in viewport", () => {
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url";
|
||||
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
|
||||
|
||||
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);
|
||||
22
packages/app/src/git/github-url.ts
Normal file
22
packages/app/src/git/github-url.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
|
||||
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
|
||||
}
|
||||
|
||||
export function buildGitHubBranchTreeUrl(input: {
|
||||
remoteUrl: string | null | undefined;
|
||||
branch: string | null | undefined;
|
||||
}): string | null {
|
||||
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
|
||||
const branch = input.branch?.trim();
|
||||
if (!repo || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
return `https://github.com/${repo}/tree/${encodedBranch}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user